diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index ed635dcdf..000000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,145 +0,0 @@ -name: JVM tests - -on: - push: - branches: [ "**" ] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 60 - if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[no ci]') - outputs: - summary: ${{ steps.summary.outputs.summary }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - submodules: recursive - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Make Gradle wrapper executable - run: chmod +x ./gradlew - - - name: Run JVM tests - id: tests - run: ./gradlew test --continue --no-daemon --console=plain - - - name: Summarize test results - id: summary - if: ${{ !cancelled() }} - shell: python - env: - TEST_OUTCOME: ${{ steps.tests.outcome }} - run: | - import os - from pathlib import Path - import xml.etree.ElementTree as ET - - reports = list(Path("build").glob("**/test-results/**/TEST-*.xml")) - totals = dict.fromkeys(("tests", "failures", "errors", "skipped"), 0) - for report in reports: - suite = ET.parse(report).getroot() - for key in totals: - totals[key] += int(suite.get(key, 0)) - - failed = totals["failures"] + totals["errors"] - passed = totals["tests"] - failed - totals["skipped"] - summary = ( - f"{totals['tests']} tests: {passed} passed, " - f"{failed} failed, {totals['skipped']} skipped." - if reports else "No JUnit XML reports were produced." - ) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write(f"summary={summary}\n") - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as output: - output.write(f"## JVM test results\n\n{summary}\n") - if totals["tests"] == 0 and os.environ["TEST_OUTCOME"] == "success": - raise SystemExit("Gradle succeeded without producing any test results.") - - - name: Upload test reports - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: test-reports - path: | - build/**/test-results/**/TEST-*.xml - build/**/reports/tests/** - if-no-files-found: warn - - notify: - needs: test - if: ${{ always() && needs.test.result != 'skipped' && github.event_name != 'pull_request' }} - runs-on: ubuntu-latest - permissions: {} - steps: - - name: Notify Telegram about test results - shell: python - env: - TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} - TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} - TG_THREAD_ID: ${{ secrets.TG_THREAD_ID }} - TEST_RESULT: ${{ needs.test.result }} - TEST_SUMMARY: ${{ needs.test.outputs.summary }} - run: | - import json - import os - from html import escape - from urllib.error import HTTPError, URLError - from urllib.request import Request, urlopen - - token = os.environ["TG_BOT_TOKEN"] - chat_id = os.environ["TG_CHAT_ID"] - if not token or not chat_id: - print("::notice::Telegram notification skipped: bot token or chat ID is not configured.") - raise SystemExit(0) - - result = os.environ["TEST_RESULT"] - repository = os.environ["GITHUB_REPOSITORY"] - branch = os.environ["GITHUB_REF_NAME"] - revision = os.environ["GITHUB_SHA"][:7] - run_url = ( - f"{os.environ['GITHUB_SERVER_URL']}/{repository}" - f"/actions/runs/{os.environ['GITHUB_RUN_ID']}" - ) - summary = os.environ["TEST_SUMMARY"] or "Test execution did not produce a summary." - text = ( - f"
πŸ§ͺ JVM tests: {escape(result.upper())}\n" - f"{escape(repository)}:{escape(branch)}\n" - f"Commit: {escape(revision)}\n" - f"{escape(summary)}\n" - f'View run and test reports
' - ) - payload = {"chat_id": chat_id, "parse_mode": "HTML", "text": text, - "disable_web_page_preview": True} - if os.environ["TG_THREAD_ID"]: - payload["message_thread_id"] = int(os.environ["TG_THREAD_ID"]) - request = Request( - f"https://api.telegram.org/bot{token}/sendMessage", - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urlopen(request, timeout=30) as response: - result = json.load(response) - except HTTPError as error: - raise SystemExit(f"Telegram notification failed: HTTP {error.code}.") from None - except URLError: - raise SystemExit("Telegram notification failed: network error.") from None - if not result.get("ok"): - raise SystemExit("Telegram rejected the notification: " + result.get("description", "unknown error")) diff --git a/addons/README.md b/addons/README.md index c0722db7f..8df43fb2e 100644 --- a/addons/README.md +++ b/addons/README.md @@ -15,6 +15,8 @@ Copy the jar to either of these directories: - `mods/` - the usual choice for distributed addons and modpacks; - `hollowengine/addons/`- useful during development or when you want to keep addons separate from mods. +In a development run of this repository nothing has to be copied: `runClient` and `runServer` build every addon first and pass `build/addon-jars` to the game with `-Dhollowengine.devAddons`. That directory is looked at before the two others, so a copy of the same addon left in `mods/` never wins, and it is watched like them: run `./gradlew buildAddons` while the game is open and the rebuilt addons are reloaded in place. Addons with bootstrap libraries still need a restart. + The runtime scans and watches both directories. If the same addon id is present in both at startup, the newest version wins; when the versions are equal, the copy in `hollowengine/addons/` has priority. Do not keep duplicate copies unless you are deliberately testing version selection. Runtime diagnostics and lifecycle controls are available to operators: diff --git a/addons/compiler/build.gradle.kts b/addons/compiler/build.gradle.kts index 8ad4820ec..e846df2f2 100644 --- a/addons/compiler/build.gradle.kts +++ b/addons/compiler/build.gradle.kts @@ -30,6 +30,10 @@ val ijPlatform = "261.25134.147" val ijJava = "261.25134.137" val runtimeProjectPath = ":runtime" +val minecraftTestClasspath = project(runtimeProjectPath).configurations.named("compileClasspath").map { classpath -> + classpath.filter { file -> listOf("org.jetbrains", "androidx", "io.insert-koin").none { file.path.contains(it) } } +} +val narrativeTestJar = provider { project(":addons:narrative").tasks.named("jar").get().archiveFile.get() } val lightTreeClasses = project.projectDir.resolve("lightTree") val patchedLightTreeJar = tasks.register("patchedLightTreeJar") { archiveBaseName.set("hollowengine-light-tree") @@ -169,7 +173,11 @@ dependencies { runtimeOnly(files(patchedLightTreeJar.flatMap { it.archiveFile })) testImplementation(kotlin("test")) + testImplementation("io.insert-koin:koin-core:${rootProject.property("koinVersion")}") testImplementation("androidx.compose.runtime:runtime:$composeRuntimeVersion") + testImplementation(files(narrativeTestJar) { builtBy(":addons:narrative:jar") }) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + testImplementation(files(minecraftTestClasspath)) testRuntimeOnly(files(patchedLightTreeJar.flatMap { it.archiveFile })) testRuntimeOnly(files(compilerPluginRuntimeJar.flatMap { it.archiveFile })) testRuntimeOnly("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") @@ -177,6 +185,7 @@ dependencies { testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") testRuntimeOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") testRuntimeOnly("org.apache.logging.log4j:log4j-api:2.23.1") + testRuntimeOnly("org.apache.logging.log4j:log4j-core:2.23.1") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -305,7 +314,8 @@ tasks.withType().configureEach { }) inputs.files(analysisClasspath).withPropertyName("ideAnalysisClasspath") doFirst { - systemProperty("hollowengine.ide.testClasspath", analysisClasspath.get().asPath) + val classpath = analysisClasspath.get().files + narrativeTestJar.get().asFile + systemProperty("hollowengine.ide.testClasspath", classpath.joinToString(File.pathSeparator)) } useJUnitPlatform() maxHeapSize = "2g" diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt index ded423347..408d4f048 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ScriptingEnvironmentFactory.kt @@ -5,10 +5,12 @@ import kotlinx.coroutines.* import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonsChangedEvent import ru.hollowhorizon.hollowengine.common.addons.publish import ru.hollowhorizon.hollowengine.common.compiler.ScriptingCompilerImpl import ru.hollowhorizon.hollowengine.common.compiler.configuration.HollowScriptConfiguration import ru.hollowhorizon.hollowengine.common.compiler.configuration.clientSideImplicitReceivers +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.ide.session.AnalysisEnvironment import ru.hollowhorizon.hollowengine.common.ide.session.EmptyLogger import ru.hollowhorizon.hollowengine.common.scripting.DefaultScriptDefinitions @@ -33,10 +35,16 @@ import kotlin.script.experimental.jvm.JvmDependency import kotlin.script.experimental.jvm.JvmGetScriptingClass import kotlin.script.experimental.jvm.defaultJvmScriptingHostConfiguration -class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, HollowAddonEntrypoint { +class ScriptingEnvironmentInitializerImpl( + private val prepareClasspath: (File) -> Pair> = CommonEnvironment::setup, + private val definitions: () -> List = DefaultScriptDefinitions::providers, +) : ScriptingEnvironmentInitializer, HollowAddonEntrypoint { @Volatile private var loaded: LoadedAddon? = null + @Volatile + private var built: BuiltEnvironment? = null + override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { loaded = LoadedAddon(context.addonFile, scope) setUp(context.addonFile, scope) @@ -45,6 +53,7 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol override suspend fun unload(context: HollowAddonContext) { loaded = null + built = null ScriptingEnvironment.clear() } @@ -56,20 +65,52 @@ class ScriptingEnvironmentInitializerImpl : ScriptingEnvironmentInitializer, Hol setUp(addon.file, addon.scope) } + /** + * Addons loaded after this one may add script types and imports. Once they are all in, the + * environment is recreated with them on the same classpath, so nothing is remapped again. + */ + @SubscribeEvent + @Synchronized + fun onAddonsChanged(event: HollowAddonsChangedEvent) { + val addon = loaded ?: return + val current = built ?: return + val scriptTypes = definitions() + if (scriptTypes == current.scriptTypes) return + logI("Reloading script definitions after addon imports or script types changed") + ScriptingEnvironment.clear() + start(addon, current.mappings, current.classpath, scriptTypes) + } + private fun setUp(addonFile: File, scope: CoroutineScope) { - val (mappings, classpath) = CommonEnvironment.setup(addonFile) + val (mappings, classpath) = prepareClasspath(addonFile) + start(LoadedAddon(addonFile, scope), mappings, classpath, definitions()) + } + + private fun start( + addon: LoadedAddon, + mappings: Mappings, + classpath: List, + scriptTypes: List, + ) { val environment = initialize( javaHome = File(System.getProperty("java.home")), classpath = classpath, - hostClasspath = classpath + addonFile, - scriptTypes = DefaultScriptDefinitions.providers(), + hostClasspath = classpath + addon.file, + scriptTypes = scriptTypes, mappings = mappings, ) - environment.warmUpAnalysis(scope) + built = BuiltEnvironment(mappings, classpath, scriptTypes) + environment.warmUpAnalysis(addon.scope) } private class LoadedAddon(val file: File, val scope: CoroutineScope) + private class BuiltEnvironment( + val mappings: Mappings, + val classpath: List, + val scriptTypes: List, + ) + override fun initialize( javaHome: File, classpath: List, diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt index 16ecdd190..49448ae42 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/CompiledScript.kt @@ -13,15 +13,19 @@ import kotlin.script.experimental.api.ScriptCompilationConfiguration import kotlin.script.experimental.api.ScriptEvaluationConfiguration import kotlin.script.experimental.api.implicitReceivers import kotlin.script.experimental.api.with +import kotlin.script.experimental.api.valueOr +import kotlin.script.experimental.api.CompiledScript as KotlinCompiledScript data class CompiledScriptImpl( override val name: String, - val script: kotlin.script.experimental.api.CompiledScript, + val script: KotlinCompiledScript, val evalConfiguration: ScriptEvaluationConfiguration, ) : CompiledScript { private val scriptClassDelegate = lazy { runScriptingBlocking { - (script.getClass(evalConfiguration) as ResultWithDiagnostics.Success).value + script.getClass(evalConfiguration).valueOr { failure -> + throw ScriptEvaluationException(name, failure.reports.map { it.convert() }) + } } } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt new file mode 100644 index 000000000..401271050 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/configuration/SharedScriptValidation.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.common.compiler.configuration + +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptImportError +import org.jetbrains.kotlin.scripting.resolve.resolvedImportScripts +import kotlin.script.experimental.api.ScriptCompilationConfiguration +import kotlin.script.experimental.api.ScriptDiagnostic +import kotlin.script.experimental.api.SourceCode + +internal fun sharedImportDiagnostics( + sources: List, + configuration: (SourceCode) -> ScriptCompilationConfiguration, +): List = buildList { + sources.forEach { script -> + val configured = configuration(script) + if (configured[ScriptCompilationConfiguration.isSharedScript] != true) return@forEach + configured[ScriptCompilationConfiguration.resolvedImportScripts].orEmpty().filter { + configuration(it)[ScriptCompilationConfiguration.isSharedScript] != true + }.forEach { dependency -> + add(ScriptDiagnostic( + ScriptDiagnostic.unspecifiedError, + sharedScriptImportError(script.locationId ?: script.name.orEmpty(), dependency.locationId ?: dependency.name.orEmpty()), + ScriptDiagnostic.Severity.ERROR, + script.locationId, + )) + } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt index 043ab7ad9..c7c65537b 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/AnnotationFirScriptJvmCompiler.kt @@ -39,7 +39,6 @@ import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.name.NameUtils import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.jetbrains.kotlin.scripting.compiler.plugin.configureFirSession -import org.jetbrains.kotlin.scripting.compiler.plugin.definitions.getOrStoreRefinedCompilationConfiguration import org.jetbrains.kotlin.scripting.compiler.plugin.definitions.getRefinedOrBaseCompilationConfiguration import org.jetbrains.kotlin.scripting.compiler.plugin.definitions.scriptRefinedCompilationConfigurationsCache import org.jetbrains.kotlin.scripting.compiler.plugin.dependencies.collectScriptsCompilationDependenciesRecursively @@ -53,6 +52,8 @@ import org.jetbrains.kotlin.scripting.compiler.plugin.impl.makeCompiledScript import org.jetbrains.kotlin.scripting.compiler.plugin.impl.refineAllForK2 import org.jetbrains.kotlin.scripting.compiler.plugin.impl.selectJvmTarget import org.jetbrains.kotlin.scripting.compiler.plugin.impl.updateWithCompilerOptions +import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition +import ru.hollowhorizon.hollowengine.common.compiler.configuration.sharedImportDiagnostics import org.jetbrains.kotlin.utils.addToStdlib.firstIsInstanceOrNull import kotlin.script.experimental.api.CompiledScript import kotlin.script.experimental.api.ResultWithDiagnostics @@ -83,6 +84,7 @@ import kotlin.script.experimental.jvm.util.toClassPathOrEmpty internal class AnnotationFirScriptJvmCompiler( state: K2ScriptingCompilerEnvironment, private val convertToFir: SourceCode.(FirSession, BaseDiagnosticsCollector) -> FirFile, + private val definitions: List, ) { private val state = state as? K2ScriptingCompilerEnvironmentInternal ?: error("Expected the internal K2 scripting compiler environment, got ${state::class}") @@ -160,12 +162,21 @@ internal class AnnotationFirScriptJvmCompiler( val allSourceFiles = mutableListOf(script) val (classpath, importedSources, sourceDependencies) = collectScriptsCompilationDependenciesRecursively(allSourceFiles) { importedScript -> - state.hostConfiguration.getOrStoreRefinedCompilationConfiguration(importedScript) { source, baseConfig -> - baseConfig.refineAll(source) + if (importedScript == script) scriptRefinedCompilationConfiguration.asSuccess() + else { + val definition = definitions.filter { importedScript.name?.endsWith(it.fileExtension) == true } + .maxByOrNull { it.fileExtension.length } + val refined = (definition?.compilationConfiguration ?: state.baseScriptCompilationConfiguration) + .refineAll(importedScript) + state.hostConfiguration[ScriptingHostConfiguration.scriptRefinedCompilationConfigurationsCache] + ?.storeRefinedCompilationConfiguration(importedScript, refined) + refined } }.valueOr { return it } allSourceFiles.addAll(importedSources) val compilationSources = importedSources + script + val sharedErrors = sharedImportDiagnostics(compilationSources, ::getRefinedConfiguration) + if (sharedErrors.isNotEmpty()) return failure(reporting, reporting.diagnosticsCollector, *sharedErrors.toTypedArray()) val ignoredOptionsReportingState = state.compilerContext.ignoredOptionsReportingState val updatedCompilerOptions = allSourceFiles.flatMapTo(mutableListOf()) { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt index 2ae79b84f..1d8513209 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/isolated/ScriptingCompilerRemapped.kt @@ -19,6 +19,12 @@ import ru.hollowhorizon.hollowengine.common.compiler.createHollowEngineCompilerP import ru.hollowhorizon.hollowengine.common.config.HollowEngineConfig import ru.hollowhorizon.hollowengine.common.files.DirectoryManager import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses +import ru.hollowhorizon.hollowengine.common.scripting.compiling.MemoryClassLoader +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptId +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptFingerprint +import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptFingerprint +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import ru.hollowhorizon.hollowengine.common.scripting.deobf.NeoForgeEnvironmentSetup import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.ClassRemappingSession import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.RemappingClasspath @@ -29,10 +35,7 @@ import ru.hollowhorizon.hollowengine.common.plugin.mixins.MixinSpecWriter import ru.hollowhorizon.hollowengine.common.scripting.compiling.isClientSideScript import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings import ru.hollowhorizon.hollowengine.common.utils.RuntimeFlags -import java.io.ByteArrayInputStream -import java.io.InputStream import java.io.Serializable -import java.security.ProtectionDomain import kotlin.script.experimental.api.* import kotlin.script.experimental.host.ScriptingHostConfiguration import kotlin.script.experimental.jvm.compilationCache @@ -110,6 +113,21 @@ class ScriptJvmCompilerRemapped( val jvmScript = script as? KJvmCompiledScript ?: return script val module = jvmScript.getCompiledModule() as? KJvmCompiledModuleInMemory ?: return jvmScript val outputFiles = module.compilerOutputFiles + extraFiles + val configuration = jvmScript.compilationConfiguration.with { + if (jvmScript.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] == true) { + val id = jvmScript.sourceLocationId?.let(ScriptRegistry::idOfLocation) + val fingerprint = id?.let(ScriptFingerprint::compute) + if (id != null && fingerprint != null) { + sharedScriptId(id.qualified) + sharedScriptFingerprint(fingerprint.code) + } + } + } + val imports = jvmScript.otherScripts.map { remapCompiledScript(it) } + val metadata = KJvmCompiledScript( + jvmScript.sourceLocationId, configuration, jvmScript.scriptClassFQName, + jvmScript.resultField, imports, null, + ) val remappingSession = if (remapToRuntime && RuntimeFlags.production && !NeoForgeEnvironmentSetup.isAvailable()) { ClassRemappingSession( mappings(), @@ -124,14 +142,14 @@ class ScriptJvmCompilerRemapped( debugSave(path, bytes) if (path.endsWith(".class")) remappingSession?.remap(bytes) ?: bytes else bytes }, - jvmScript.otherScripts, + listOf(metadata), ) return KJvmCompiledScript( jvmScript.sourceLocationId, - jvmScript.compilationConfiguration, + configuration, jvmScript.scriptClassFQName, jvmScript.resultField, - jvmScript.otherScripts.map { remapCompiledScript(it) }, + imports, remappedModule, ) } @@ -168,6 +186,7 @@ class HollowEngineScriptCompiler( ): ResultWithDiagnostics = withMessageCollector { messageCollector -> withScriptCompilationCache(script, scriptCompilationConfiguration, messageCollector) { withConfiguredK2ScriptCompilerWithLightTree( + definitions, scriptCompilationConfiguration.with { hostConfiguration(this@HollowEngineScriptCompiler.hostConfiguration) }, @@ -193,6 +212,7 @@ class HollowEngineScriptCompiler( } private fun withConfiguredK2ScriptCompilerWithLightTree( + definitions: List, scriptCompilationConfiguration: ScriptCompilationConfiguration, parentMessageCollector: MessageCollector? = null, configureCompiler: CompilerConfiguration.() -> Unit = {}, @@ -209,7 +229,8 @@ private fun withConfiguredK2ScriptCompilerWithLightTree( ?: defaultJvmScriptingHostConfiguration, configureCompiler ), - SourceCode::convertToFirViaLightTree + SourceCode::convertToFirViaLightTree, + definitions, ) ) } finally { @@ -246,40 +267,8 @@ private class RemappedCompiledModule( ) : KJvmCompiledModuleInMemory, Serializable { override fun createClassLoader(baseClassLoader: ClassLoader?): ClassLoader { val parent = baseClassLoader ?: HollowEngine::class.java.classLoader - return RemappedCompiledScriptClassLoader( - compilerOutputFiles, - parent, - SharedScriptClasses.loadersFor(imports, compilerOutputFiles, parent), - ) - } -} - -private class RemappedCompiledScriptClassLoader( - private val files: Map, - parent: ClassLoader?, - private val shared: Map, -) : ClassLoader(parent) { - override fun loadClass(name: String, resolve: Boolean): Class<*> { - val owner = sharedOwnerOf(name) ?: return super.loadClass(name, resolve) - return owner.loadClass(name).also { if (resolve) resolveClass(it) } - } - - /** Nested and synthetic classes belong to the script that declared them: `Quests$Payload` to `Quests`. */ - private fun sharedOwnerOf(name: String): ClassLoader? { - if (shared.isEmpty()) return null - shared[name]?.let { return it } - val outer = name.substringBefore('$') - return if (outer == name) null else shared[outer] - } - - override fun findClass(name: String): Class<*> { - val path = name.replace('.', '/') + ".class" - val bytes = files[path] ?: throw ClassNotFoundException(name) - val protectionDomain = ProtectionDomain(null, null) - return defineClass(name, bytes, 0, bytes.size, protectionDomain) - } - - override fun getResourceAsStream(name: String): InputStream? { - return files[name]?.let(::ByteArrayInputStream) + return MemoryClassLoader(compilerOutputFiles, parent).apply { + shareClassesOf(SharedScriptClasses.loadersFor(imports, compilerOutputFiles, parent)) + } } } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt index fdff60cf6..22db52f2b 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/AnalysisEnvironment.kt @@ -512,7 +512,7 @@ class AnalysisEnvironment( implementationClassName, DefaultPluginDescriptor("hollowengine-analysis-api"), ) - point.registerExtension(extension, LoadingOrder.ANY, projectDisposable) + point.registerExtension(extension, LoadingOrder.ANY, kotlinCoreProjectEnvironment.environment.parentDisposable) } private fun MockProject.registerFirAnalysisServices() { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/KotlinSnippets.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/KotlinSnippets.kt new file mode 100644 index 000000000..3fbc2f48f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/KotlinSnippets.kt @@ -0,0 +1,93 @@ +package ru.hollowhorizon.hollowengine.common.ide.session + +import com.intellij.psi.PsiErrorElement +import com.intellij.psi.impl.source.tree.LeafPsiElement +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.types.KaErrorType +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelector +import org.jetbrains.kotlin.types.Variance +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinName +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinPropertyTypes +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinSyntax +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinToken +import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType + +private const val PREFIX = "fun snippet() {\n" +private const val SUFFIX = "\n}" + +/** Parses [text] as the body of a function, which takes an expression and statements alike. */ +internal fun kotlinSyntax(factory: KtPsiFactory, text: String): KotlinSyntax { + val file = factory.createFile("snippet.kt", PREFIX + text + SUFFIX) + val body = (file.declarations.singleOrNull() as? KtNamedFunction)?.bodyBlockExpression + ?: return KotlinSyntax(valid = false) + val valid = PsiTreeUtil.findChildOfType(file, PsiErrorElement::class.java) == null + return KotlinSyntax(valid, arguments(body, text), names(body), tokens(body)) +} + +/** The properties of [file] named from [values] and from [places], and which of the latter can hold each of the former. */ +@OptIn(KaExperimentalApi::class) +internal fun propertyTypes(file: KtFile, values: String, places: String?): KotlinPropertyTypes = analyze(file) { + fun named(prefix: String?) = file.collectDescendantsOfType { prefix != null && it.name?.startsWith(prefix) == true } + .mapNotNull { property -> property.symbol.returnType.takeUnless { it is KaErrorType }?.let { property.name!! to it } } + .toMap() + val found = named(values) + val wanted = named(places) + KotlinPropertyTypes( + types = (found + wanted).mapValues { (_, type) -> + type.render(KaTypeRendererForSource.WITH_QUALIFIED_NAMES, Variance.INVARIANT) + }, + fits = found.mapValues { (_, type) -> + val value = type.withNullability(false) + wanted.filterValues { value.isSubtypeOf(it) }.keys + }, + ) +} + +/** The leaves of [body] that have a color of their own. */ +private fun tokens(body: KtBlockExpression): List = body.collectDescendantsOfType().mapNotNull { leaf -> + val type = when (leaf.elementType) { + in KtTokens.KEYWORDS -> TokenType.KEYWORD + in KtTokens.SOFT_KEYWORDS -> if (leaf.parent is KtModifierList) TokenType.KEYWORD else return@mapNotNull null + in KtTokens.STRINGS, KtTokens.ESCAPE_SEQUENCE -> TokenType.STRING + KtTokens.INTEGER_LITERAL, KtTokens.FLOAT_LITERAL -> TokenType.NUMERIC_LITERAL + in KtTokens.COMMENTS -> TokenType.COMMENT + else -> return@mapNotNull null + } + val start = leaf.textRange.startOffset - PREFIX.length + KotlinToken(start, start + leaf.textLength, type) +} + +/** The text between the commas of the call [body] consists of, commas of what it holds aside. */ +private fun arguments(body: KtBlockExpression, text: String): List? { + val list = (body.statements.singleOrNull() as? KtCallExpression)?.valueArgumentList ?: return null + if (list.rightParenthesis?.textRange?.endOffset != PREFIX.length + text.length) return null + return split(list, text) +} + +private fun split(list: KtValueArgumentList, text: String): List { + val commas = + list.node.getChildren(null).filter { it.elementType == KtTokens.COMMA }.map { it.startOffset - PREFIX.length } + val open = list.leftParenthesis!!.textRange.endOffset - PREFIX.length + val close = list.rightParenthesis!!.textRange.startOffset - PREFIX.length + val starts = listOf(open) + commas.map { it + 1 } + return starts.zip(commas + close) { start, end -> text.substring(start, end).trim() } +} + +/** The names that mean something declared outside of [body]: its own locals and members of other things do not. */ +private fun names(body: KtBlockExpression): List { + val local = PsiTreeUtil.collectElementsOfType(body, KtDeclaration::class.java) + .filter { it is KtProperty || it is KtParameter }.mapNotNullTo(HashSet()) { it.name } + return PsiTreeUtil.collectElementsOfType(body, KtNameReferenceExpression::class.java).filter { name -> + val parent = name.parent + name.getReferencedName() !in local && name.getQualifiedExpressionForSelector() == null && parent !is KtValueArgumentName && parent !is KtUserType && (parent as? KtCallExpression)?.calleeExpression != name + }.map { name -> + val start = name.textRange.startOffset - PREFIX.length + KotlinName(name.getReferencedName(), start, start + name.textLength) + }.sortedBy { it.start } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt index 43e2cde87..c3dc0f884 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/ScriptingAnalyzerImpl.kt @@ -13,7 +13,10 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory import org.jetbrains.kotlin.psi.KtUserType import ru.hollowhorizon.hollowengine.common.files.DirectoryManager +import ru.hollowhorizon.hollowengine.common.files.DirectoryManager.fromReadablePath +import ru.hollowhorizon.hollowengine.common.ide.session.completion.checkpointNameCompletions import ru.hollowhorizon.hollowengine.common.ide.session.completion.createCompletions +import ru.hollowhorizon.hollowengine.common.ide.session.completion.findAcceptedExpressionTypes import ru.hollowhorizon.hollowengine.common.ide.session.definition.LibraryNavigation import ru.hollowhorizon.hollowengine.common.ide.session.definition.findDefinition import ru.hollowhorizon.hollowengine.common.ide.session.diagnostic.diagnosticCode @@ -27,10 +30,12 @@ import ru.hollowhorizon.hollowengine.common.ide.session.insight.findSignatureHel import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaKotlinSourceModule import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaRekotLibraryModule import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaScriptModule +import ru.hollowhorizon.hollowengine.common.ide.session.rename.SymbolRename import ru.hollowhorizon.hollowengine.common.ide.session.structure.editorStructure import ru.hollowhorizon.hollowengine.common.ide.session.structure.kotlinGutterTargets import ru.hollowhorizon.hollowengine.common.ide.session.symbols.searchSymbols import ru.hollowhorizon.hollowengine.common.ide.session.symbols.symbolSource +import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptImportError import ru.hollowhorizon.hollowengine.common.scripting.ide.* import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports @@ -52,6 +57,9 @@ class ScriptingAnalyzerImpl internal constructor( get() = kotlinCoreProjectEnvironment.project private val factory = KtPsiFactory(project, eventSystemEnabled = true) + /** Parses for fields as they are typed: on files of its own, so it never waits for analysis to finish. */ + private val snippetFactory = KtPsiFactory(project, eventSystemEnabled = false) + internal val fileCache = object : LinkedHashMap(5, 0.75f, true) { override fun removeEldestEntry(entry: MutableMap.MutableEntry?): Boolean { val shouldRemove = size > 5 @@ -124,8 +132,7 @@ class ScriptingAnalyzerImpl internal constructor( updateLibrarySourcesModule() } else { projectStructureProvider.setModule( - file, - KaKotlinSourceModule(project, listOf(file), sourceDependencies, classpath) + file, KaKotlinSourceModule(project, listOf(file), sourceDependencies, classpath) ) } return file @@ -231,7 +238,11 @@ class ScriptingAnalyzerImpl internal constructor( ) return@argumentLoop } - importedFiles += createPsiFile(importedName, importedText, files, activeFiles) + val importedFile = createPsiFile(importedName, importedText, files, activeFiles) + if (file.isSharedScript() && !importedFile.isSharedScript()) { + diagnostics += importDiagnostic(file, expression, sharedScriptImportError(file.name, importedName)) + } + importedFiles += importedFile } } return ImportResolution(importedFiles, diagnostics) @@ -254,6 +265,8 @@ class ScriptingAnalyzerImpl internal constructor( /** The path [HollowIdeModel][ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeModel] opens files by. */ private fun editorPathOf(file: File): String { + ScriptRegistry.idOf(file)?.takeIf { it.namespace != ScriptRegistry.sandboxNamespace } + ?.let { return it.qualified } val root = DirectoryManager.HOLLOW_ENGINE.toAbsolutePath().normalize() val path = file.toPath().toAbsolutePath().normalize() if (!path.startsWith(root)) return file.name @@ -302,7 +315,10 @@ class ScriptingAnalyzerImpl internal constructor( } override fun completions(name: String, text: String, offset: Int, sink: CompletionSink) = synchronized(lock) { - createCompletions(getOrCreateFile(name, text), offset, sink) + val file = getOrCreateFile(name, text) + val checkpoints = checkpointNameCompletions(file, offset) + if (checkpoints != null) sink.emit(checkpoints) else createCompletions(file, offset, sink) + Unit } override fun definition(name: String, text: String, offset: Int): DefinitionLocation? = synchronized(lock) { @@ -315,7 +331,70 @@ class ScriptingAnalyzerImpl internal constructor( override fun gutterTargets(name: String, text: String, offset: Int): List = synchronized(lock) { - kotlinGutterTargets(getOrCreateFile(name, text), offset) + kotlinGutterTargets(getOrCreateFile(name, text), name, offset) + } + + override fun acceptedExpressionTypes(name: String, text: String, offset: Int, types: List): List? = + synchronized(lock) { findAcceptedExpressionTypes(getOrCreateFile(name, text), offset, types) } + + override fun kotlinSyntax(text: String): KotlinSyntax = kotlinSyntax(snippetFactory, text.withUnixLineSeparators()) + + override fun propertyTypes(name: String, text: String, values: String, places: String?): KotlinPropertyTypes = + synchronized(lock) { propertyTypes(getOrCreateFile(name, text), values, places) } + + override fun forget(name: String) = synchronized(lock) { + fileCache.remove(name)?.let { cleanupFiles(it.relatedFiles) } + Unit + } + + override fun renameTarget(name: String, text: String, offset: Int): RenameTarget? = synchronized(lock) { + SymbolRename.target(getOrCreateFile(name, text), offset) { it == name || isProjectScript(it) }?.target + } + + override fun rename( + name: String, + text: String, + offset: Int, + newName: String, + documents: Map, + ): List = synchronized(lock) { + if (!SymbolRename.accepts(newName)) return emptyList() + val renamed = SymbolRename.target(getOrCreateFile(name, text), offset) { it == name || isProjectScript(it) } + ?: return emptyList() + if (renamed.name == newName) return emptyList() + val texts = LinkedHashMap() + texts[name] = text + if (renamed.key.project) { + projectScripts().forEach { (path, file) -> + texts.getOrPut(path) { + documents[path] ?: file.readTextOrNull() ?: return@forEach + } + } + } else if (renamed.key.path != name) { + val declaring = renamed.key.path + documents[declaring]?.let { texts[declaring] = it } ?: declaring.fromReadablePath().readTextOrNull() + ?.let { texts[declaring] = it } + } + val word = Regex("(? + val normalized = source.withUnixLineSeparators() + if (!word.containsMatchIn(normalized)) return@mapNotNull null + if (!renamed.key.project && path != renamed.key.path) return@mapNotNull null + val edits = SymbolRename.edits(getOrCreateFile(path, normalized), renamed.key, renamed.name, newName) + edits.takeIf { it.isNotEmpty() }?.let { FileRename(path, normalized, it) } + } + } + + /** Whether the script the editor opens as [path] is a file of the project, not one shipped by an addon. */ + private fun isProjectScript(path: String): Boolean { + val file = runCatching { path.fromReadablePath() }.getOrNull() ?: return false + return file.isFile && projectScripts().any { it.second == file.absoluteFile.normalize() } + } + + /** The scripts of the project, by the path the editor opens them with. */ + private fun projectScripts(): List> = + ScriptRegistry.listIn(ScriptRegistry.sandboxNamespace, ".kts").mapNotNull { id -> + ScriptRegistry.artifacts(id)?.sourceFile?.absoluteFile?.normalize()?.let { editorPathOf(it) to it } } override fun signatureHelp(name: String, text: String, offset: Int): SignatureHelp? = synchronized(lock) { @@ -356,6 +435,12 @@ private fun removeFromPsiManager(file: KtFile) { fileManager.setViewProvider(file.virtualFile, null) } +private fun File.readTextOrNull(): String? = try { + readText() +} catch (_: IOException) { + null +} + private fun String.withUnixLineSeparators(): String = replace("\r\n", "\n").replace('\r', '\n') val KtAnnotationEntry.typeName: String? get() = (typeReference?.typeElement as? KtUserType)?.referencedName diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt new file mode 100644 index 000000000..1e1310295 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/SharedScriptPolicy.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.common.ide.session + +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.scripting.definitions.ScriptDefinitionProvider +import org.jetbrains.kotlin.scripting.resolve.KtFileScriptSource +import ru.hollowhorizon.hollowengine.common.scripting.annotations.SharedScript +import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript +import kotlin.script.experimental.api.ScriptCompilationConfiguration + +internal fun KtFile.isSharedScript(): Boolean { + val definition = ScriptDefinitionProvider.getInstance(project)?.findDefinition(KtFileScriptSource(this)) + if (definition?.compilationConfiguration?.get(ScriptCompilationConfiguration.isSharedScript) == true) return true + val marker = SharedScript::class.qualifiedName!! + val aliases = importDirectives.mapNotNull { directive -> + directive.importPath?.takeIf { it.fqName.asString() == marker } + ?.let { directive.aliasName ?: it.fqName.shortName().asString() } + }.toMutableSet() + val shadowed = importDirectives.any { directive -> + val path = directive.importPath ?: return@any false + !path.isAllUnder && (directive.aliasName ?: path.fqName.shortName().asString()) == "SharedScript" && + path.fqName.asString() != marker + } + if (!shadowed) aliases += "SharedScript" + return annotationEntries.any { entry -> + val name = entry.typeReference?.text?.replace("`", "") + name == marker || name in aliases + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt new file mode 100644 index 000000000..a5e4c286a --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CheckpointNameCompletions.kt @@ -0,0 +1,51 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.completion + +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtValueArgument +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItemTag +import ru.hollowhorizon.hollowengine.common.scripting.ide.declarationCompletionItem + +/** + * The checkpoints of [file] for the string a story names one in, `rewind("…")` or `temporary(until = "…")`, or null + * when the caret at [offset] is not in such a string and the ordinary completion should run. + */ +internal fun checkpointNameCompletions(file: KtFile, offset: Int): List? { + val element = file.findElementAt(offset) ?: file.findElementAt(offset - 1) ?: return null + val template = element.getParentOfType(false) ?: return null + if (offset <= template.textRange.startOffset || offset >= template.textRange.endOffset) return null + val argument = template.parent as? KtValueArgument ?: return null + val call = argument.getParentOfType(true) ?: return null + val named = argument.getArgumentName()?.asName?.asString() + val namesCheckpoint = when (call.calleeExpression?.text) { + "rewind" -> named == "checkpoint" || named == null && call.valueArguments.firstOrNull() === argument + "temporary" -> named == "until" + else -> false + } + if (!namesCheckpoint) return null + val prefix = file.text.substring(template.textRange.startOffset + 1, offset) + return checkpointNames(file).filter { it.startsWith(prefix) }.map { name -> + declarationCompletionItem { + show = name + insert = name + this.name = name + tag = CompletionItemTag.PROPERTY + fqName = null + wordChars = "-." + } + } +} + +/** The names the checkpoints of [file] are given as constants. */ +internal fun checkpointNames(file: KtFile): List = file.collectDescendantsOfType() + .filter { it.calleeExpression?.text == "checkpoint" } + .mapNotNull { call -> + val template = call.valueArguments.firstOrNull()?.getArgumentExpression() as? KtStringTemplateExpression + (template?.entries?.singleOrNull() as? KtLiteralStringTemplateEntry)?.text + } + .distinct() diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt index 778c0cd51..6051816d5 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/CompletionsProvider.kt @@ -15,6 +15,7 @@ import org.jetbrains.kotlin.analysis.api.scopes.KaTypeScope import org.jetbrains.kotlin.analysis.api.signatures.KaCallableSignature import org.jetbrains.kotlin.analysis.api.symbols.* import org.jetbrains.kotlin.analysis.api.symbols.markers.KaDeclarationContainerSymbol +import org.jetbrains.kotlin.analysis.api.types.KaErrorType import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.lexer.KtTokens @@ -56,7 +57,7 @@ fun ScriptingAnalyzerImpl.createCompletions(file: KtFile, offset: Int, sink: Com } } -private fun ScriptingAnalyzerImpl.createFileForCompletion(original: KtFile, offset: Int): KtFile { +internal fun ScriptingAnalyzerImpl.createFileForCompletion(original: KtFile, offset: Int): KtFile { val safeOffset = offset.coerceIn(0, original.textLength) val textWithInsertedFakeIdentifier = original.text.replaceRange(safeOffset, safeOffset, COMPLETION_FAKE_IDENTIFIER) val copyKtFile = KtPsiFactory(original.project, markGenerated = true, eventSystemEnabled = true).createFile( @@ -337,30 +338,30 @@ private fun CompletionItemsCollector.completeAfterDot( is KaPackageSymbol -> { phase(CompletionCloseness.SCOPE) completePackage(file, receiverTarget.fqName, nameFilter, onlyPackages) + return@with } - else -> { - val receiverType = position.receiver.expressionType - - if (receiverType == null || receiverType is org.jetbrains.kotlin.analysis.api.types.KaErrorType) return@with + else -> Unit + } - phase(CompletionCloseness.MEMBER) - receiverType.scope?.let { completeTypeScope(it) } + val receiverType = position.receiver.expressionType + if (receiverType == null || receiverType is KaErrorType) return@with - if (position.prefix != null) { - phase(CompletionCloseness.SCOPE) - val scopeContext = file.scopeContext(element) - for (scope in scopeContext.scopes) { - add(scope.scope.callables(nameFilter).filterShadowedJavaFields().filter { it.isExtension }) - } - } + phase(CompletionCloseness.MEMBER) + receiverType.scope?.let { completeTypeScope(it) } - phase(CompletionCloseness.IMPORTED) - add(getKotlinDeclarationsFromIndex(file, nameFilter).filter { symbol -> - symbol is KaCallableSymbol && symbol.isExtension - }) { withImport() } + if (position.prefix != null) { + phase(CompletionCloseness.SCOPE) + val scopeContext = file.scopeContext(element) + for (scope in scopeContext.scopes) { + add(scope.scope.callables(nameFilter).filterShadowedJavaFields().filter { it.isExtension }) } } + + phase(CompletionCloseness.IMPORTED) + add(getKotlinDeclarationsFromIndex(file, nameFilter).filter { symbol -> + symbol is KaCallableSymbol && symbol.isExtension + }) { withImport() } } context(kaSession: KaSession) @@ -550,7 +551,7 @@ private fun CompletionItemsCollector.completeScope(scope: KaScope) { } context(_: KaSession) -private fun getPosition(element: KtElement): CompletionPosition? { +internal fun getPosition(element: KtElement): CompletionPosition? { // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ°, находимся Π»ΠΈ ΠΌΡ‹ Π² Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΈΠ²Π΅ package ΠΈΠ»ΠΈ import val importDirective = element.parentOfType(withSelf = true) val packageDirective = element.parentOfType(withSelf = true) @@ -699,7 +700,7 @@ fun KtUserType.isAnnotation(): Boolean { return constructor.parent is KtAnnotationEntry } -private sealed interface CompletionPosition { +internal sealed interface CompletionPosition { val prefix: String? class Identifier(override val prefix: String) : CompletionPosition diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt new file mode 100644 index 000000000..e87097fce --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/completion/ExpressionSlots.kt @@ -0,0 +1,41 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.completion + +import com.intellij.psi.util.parentOfType +import org.jetbrains.kotlin.analysis.api.analyzeCopy +import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode +import org.jetbrains.kotlin.analysis.api.types.KaErrorType +import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import ru.hollowhorizon.hollowengine.common.ide.session.ScriptingAnalyzerImpl + +/** + * Which of [types] an expression typed at [offset] may have. Null when [offset] is not where an expression + * starts: after a dot, in a declaration name, in an import or a type. Where nothing is expected, or where the + * value is thrown away as a statement, every candidate fits. + */ +fun ScriptingAnalyzerImpl.findAcceptedExpressionTypes(file: KtFile, offset: Int, types: List): List? { + val safeOffset = offset.coerceIn(0, file.textLength) + val copy = createFileForCompletion(file, safeOffset) + try { + val reference = copy.findElementAt(safeOffset)?.parentOfType(withSelf = true) + ?: return null + val call = reference.parent as? KtCallExpression + val expression: KtExpression = if (call?.calleeExpression == reference) call else reference + + return analyzeCopy(expression, KaDanglingFileResolutionMode.PREFER_SELF) { + if (getPosition(reference) !is CompletionPosition.Identifier) return@analyzeCopy null + val expected = expression.expectedType + if (expected == null || expected is KaErrorType || expected is KaTypeParameterType || expected.isUnitType) { + return@analyzeCopy types + } + types.filter { fqName -> buildClassType(ClassId.topLevel(FqName(fqName))).isSubtypeOf(expected) } + } + } finally { + cleanupFile(copy) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/AnimationNameDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/AnimationNameDiagnostics.kt new file mode 100644 index 000000000..e64f54830 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/AnimationNameDiagnostics.kt @@ -0,0 +1,88 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.diagnostic + +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall +import org.jetbrains.kotlin.analysis.api.resolution.KaSuccessCallInfo +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.* +import ru.hollowhorizon.hollowengine.common.scripting.ide.AnimationClips +import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic +import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity +import ru.hollowhorizon.hollowengine.common.utils.Spelling + +private const val EDITOR = "ru.hollowhorizon.hollowengine.common.attachments.editor" +private val AnimationClassId = ClassId.topLevel(FqName("$EDITOR.EditorAnimation")) +private val ModelClassId = ClassId.topLevel(FqName("$EDITOR.EditorModel")) +private val NpcIdClassId = ClassId.topLevel(FqName("$EDITOR.EditorNpcId")) + +internal fun animationNameDiagnostics(file: KtFile, clips: AnimationClips): List { + val calls = PsiTreeUtil.collectElementsOfType(file, KtCallExpression::class.java) + if (calls.isEmpty()) return emptyList() + val document = file.fileDocument + val diagnostics = ArrayList() + analyze(file) { + for (call in calls) { + val argument = call.function()?.argumentWith(AnimationClassId) ?: continue + val clip = argument.literal() ?: continue + val qualified = call.parent as? KtQualifiedExpression + val receiver = qualified?.takeIf { it.selectorExpression == call }?.receiverExpression ?: continue + val names = modelOf(receiver, clips, 0)?.let(clips::of) ?: continue + if (clip in names) continue + diagnostics += Diagnostic(document.rangeOf(argument.textRange), Severity.WARNING, message(clip, names)) + } + } + return diagnostics +} + +private fun message(clip: String, names: List): String { + if (names.isEmpty()) return "This model has no animations" + val similar = Spelling.similar(clip, names) + val missing = "This model has no animation \"$clip\"" + return if (similar.isEmpty()) missing else "$missing. Did you mean: ${similar.joinToString()}?" +} + +context(session: KaSession) +private fun KtCallExpression.function(): KaFunctionCall<*>? = with(session) { + runCatching { (resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> }.getOrNull() +} + +context(session: KaSession) +private fun KaFunctionCall<*>.argumentWith(annotation: ClassId): KtExpression? = with(session) { + argumentMapping.entries.firstOrNull { (_, parameter) -> annotation in parameter.symbol.annotations }?.key +} + +private const val MAX_HOPS = 4 + +context(session: KaSession) +private fun modelOf(expression: KtExpression, clips: AnimationClips, hops: Int): String? = with(session) { + if (hops > MAX_HOPS) return null + when (val value = KtPsiUtil.safeDeparenthesize(expression)) { + is KtCallExpression -> made(value, clips) + is KtQualifiedExpression -> (value.selectorExpression as? KtCallExpression)?.let { made(it, clips) } + is KtNameReferenceExpression -> { + val property = runCatching { value.mainReference.resolveToSymbol()?.psi }.getOrNull() as? KtProperty + property?.takeUnless { it.isVar }?.initializer?.let { modelOf(it, clips, hops + 1) } + } + + else -> null + } +} + +context(session: KaSession) +private fun made(call: KtCallExpression, clips: AnimationClips): String? { + val function = call.function() ?: return null + return function.argumentWith(ModelClassId)?.literal()?.takeIf { it.isNotEmpty() } ?: function.argumentWith( + NpcIdClassId + )?.literal()?.let(clips::modelOfNpc) +} + +private fun KtExpression.literal(): String? { + val template = this as? KtStringTemplateExpression ?: return null + if (template.hasInterpolation()) return null + val parts = template.entries.map { entry -> (entry as? KtLiteralStringTemplateEntry)?.text ?: return null } + return parts.joinToString("") +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt index 507144b9e..eb9438a0a 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/diagnostic/Diagnostic.kt @@ -9,33 +9,37 @@ import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtProperty -import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic -import ru.hollowhorizon.hollowengine.common.scripting.ide.Position -import ru.hollowhorizon.hollowengine.common.scripting.ide.Range -import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity +import ru.hollowhorizon.hollowengine.client.editor.GameAnimationClips +import ru.hollowhorizon.hollowengine.common.scripting.ide.* +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient fun diagnosticCode(file: KtFile): List { val document = file.fileDocument val analysisDiagnostics = analyze(file) { file.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS).map { diagnostic -> - val severity = when (diagnostic.severity) { - KaSeverity.INFO -> Severity.INFO - KaSeverity.ERROR -> Severity.ERROR - KaSeverity.WARNING -> Severity.WARNING - } - val range = diagnostic.textRanges.firstOrNull() ?: diagnostic.psi.textRange - val property = diagnostic.psi as? KtProperty - val fixes = if (diagnostic.factoryName == "UNUSED_VARIABLE" && property != null) { - listOfNotNull(unusedVariableFix(property)) - } else emptyList() - Diagnostic(document.rangeOf(range), severity, diagnostic.defaultMessage, fixes) + val severity = when (diagnostic.severity) { + KaSeverity.INFO -> Severity.INFO + KaSeverity.ERROR -> Severity.ERROR + KaSeverity.WARNING -> Severity.WARNING } - } - val inspections = listOf(::unusedImportDiagnostics, ::subscribeEventDiagnostics).flatMap { inspection -> - runCatching { - inspection(file) - }.getOrDefault(emptyList()) + val range = diagnostic.textRanges.firstOrNull() ?: diagnostic.psi.textRange + val property = diagnostic.psi as? KtProperty + val fixes = if (diagnostic.factoryName == "UNUSED_VARIABLE" && property != null) { + listOfNotNull(unusedVariableFix(property)) + } else emptyList() + Diagnostic(document.rangeOf(range), severity, diagnostic.defaultMessage, fixes) } + } + val clips = if (isPhysicalClient) GameAnimationClips else AnimationClips.None + val inspections = listOf( + ::unusedImportDiagnostics, + ::subscribeEventDiagnostics, + { it: KtFile -> animationNameDiagnostics(it, clips) }, + ).flatMap { inspection -> + runCatching { + inspection(file) + }.getOrDefault(emptyList()) + } return (analysisDiagnostics + syntaxDiagnostics(file) + inspections).distinctBy { diagnostic -> Triple(diagnostic.range, diagnostic.severity, diagnostic.message) } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/rename/SymbolRename.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/rename/SymbolRename.kt new file mode 100644 index 000000000..e349dea9e --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/rename/SymbolRename.kt @@ -0,0 +1,95 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.rename + +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf +import ru.hollowhorizon.hollowengine.common.scripting.ide.RenameTarget +import ru.hollowhorizon.hollowengine.common.scripting.ide.TextEdit + +internal data class DeclarationKey(val path: String, val chain: String, val project: Boolean) + +internal data class RenamedDeclaration(val key: DeclarationKey, val name: String, val target: RenameTarget) + +internal object SymbolRename { + private val identifier = Regex("[A-Za-z_][A-Za-z0-9_]*") + + fun target(file: KtFile, offset: Int, editable: (String) -> Boolean): RenamedDeclaration? { + val leaf = identifierAt(file, offset) ?: return null + val key = analyze(file) { keysOf(leaf).firstOrNull() } ?: return null + if (!editable(key.path)) return null + val name = leaf.text.removeSurrounding("`") + return RenamedDeclaration(key, name, RenameTarget(name, leaf.textRange.startOffset, leaf.textRange.endOffset)) + } + + fun accepts(name: String): Boolean = identifier.matches(name) + + fun edits(file: KtFile, key: DeclarationKey, oldName: String, newName: String): List { + val written = if (newName in hardKeywords) "`$newName`" else newName + val edits = ArrayList() + analyze(file) { + var leaf: PsiElement? = PsiTreeUtil.firstChild(file) + while (leaf != null) { + if (leaf.node?.elementType == KtTokens.IDENTIFIER && + leaf.text.removeSurrounding("`") == oldName && + key in keysOf(leaf) + ) { + edits += TextEdit(leaf.textRange.startOffset, leaf.textRange.endOffset, written) + } + leaf = PsiTreeUtil.nextLeaf(leaf) + } + } + return edits + } + + private fun identifierAt(file: KtFile, offset: Int): PsiElement? { + val safe = offset.coerceIn(0, file.textLength) + return listOfNotNull( + file.findElementAt(safe), if (safe > 0) file.findElementAt(safe - 1) else null + ).firstOrNull { it.node?.elementType == KtTokens.IDENTIFIER } + } + + context(session: KaSession) + private fun keysOf(leaf: PsiElement): List = with(session) { + val parent = leaf.parent + val symbols: List = runCatching { + when (parent) { + is KtSimpleNameExpression -> parent.mainReference.resolveToSymbols().toList() + is KtNamedDeclaration if parent.nameIdentifier == leaf -> listOf(parent.symbol) + else -> emptyList() + } + }.getOrElse { emptyList() } + + symbols.mapNotNull { symbol -> + val declared = if (symbol is KaConstructorSymbol) symbol.containingSymbol ?: symbol else symbol + (declared.psi as? KtNamedDeclaration)?.let(::keyOf) + } + } + + private fun keyOf(declaration: KtNamedDeclaration): DeclarationKey? { + val file = declaration.containingFile as? KtFile ?: return null + val local = isLocal(declaration) + val chain = buildString { + declaration.parentsWithSelf.filterIsInstance().toList().asReversed().forEach { + append('/').append(it.name) + if (it is KtFunction) append('(').append(it.valueParameters.size).append(')') + } + if (local) append('@').append(declaration.textOffset) + } + val private = declaration.parentsWithSelf.filterIsInstance() + .any { it.hasModifier(KtTokens.PRIVATE_KEYWORD) } + return DeclarationKey(file.name, chain, project = !local && !private) + } + + private fun isLocal(declaration: KtNamedDeclaration): Boolean = declaration.parentsWithSelf.drop(1).any { parent -> + parent is KtFunctionLiteral || parent is KtBlockExpression && parent.parent !is KtScript + } + + private val hardKeywords = KtTokens.KEYWORDS.types.map { it.toString() }.toSet() +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt index 5749d0b01..7d49e00d6 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/KotlinEditorStructure.kt @@ -13,6 +13,7 @@ import org.jetbrains.kotlin.kdoc.psi.api.KDoc import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf @@ -73,6 +74,7 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur val symbol = declaration.markerSymbol() if (symbol is KaNamedFunctionSymbol) { if (EventClassId in symbol.annotations) markers += GutterMarker(offset, GutterMarkerKind.EVENT) + if (ActionClassId in symbol.annotations) markers += GutterMarker(offset, GutterMarkerKind.ACTION) if (node && !attached && StateClassId in symbol.annotations && declaration is KtNamedFunction && declaration.parent == file.script?.blockExpression && symbol.isSuspend && symbol.valueParameters.isEmpty() && !symbol.isExtension && symbol.contextParameters.isEmpty() && declaration.bodyExpression != null) { val annotation = symbol.annotations.firstOrNull { it.classId == StateClassId } val value = annotation?.arguments?.firstOrNull { it.name.asString() == "name" }?.expression @@ -90,6 +92,7 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur } } val coroutineLines = HashSet() + val story = StoryGutterMarkers(this, file) file.collectDescendantsOfType().forEach { expression -> val offset = when (expression) { is KtCallExpression -> expression.calleeExpression?.textOffset @@ -99,11 +102,13 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur else -> null } ?: return@forEach val call = (expression.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> ?: return@forEach - if ((call.symbol as? KaNamedFunctionSymbol)?.isSuspend == true && coroutineLines.add( - file.text.lastIndexOf( - '\n', offset - ) + 1 - ) + val line = file.text.lastIndexOf('\n', offset) + 1 + story.markerFor(expression, call)?.let { marker -> + markers += marker.copy(offset = offset) + coroutineLines += line + return@forEach + } + if ((call.symbol as? KaNamedFunctionSymbol)?.isSuspend == true && coroutineLines.add(line) ) { markers += GutterMarker(offset, GutterMarkerKind.COROUTINE) } @@ -112,8 +117,18 @@ internal fun ScriptingAnalyzerImpl.editorStructure(file: KtFile): EditorStructur return EditorStructure(folds.distinctBy { it.start to it.end }, markers.distinctBy { it.offset to it.kind }, runs) } -/** The declarations an override marker at [offset] leads to; an accessor marker leads to the base accessors. */ -internal fun ScriptingAnalyzerImpl.kotlinGutterTargets(file: KtFile, offset: Int): List { +/** + * Where the marker at [offset] of the file [path] leads: an override marker to the declarations it overrides, an + * accessor marker to the base accessors, and a `rewind` or `temporary` marker to its checkpoint. + */ +internal fun ScriptingAnalyzerImpl.kotlinGutterTargets(file: KtFile, path: String, offset: Int): List { + val story = file.findElementAt(offset)?.parentsWithSelf?.filterIsInstance() + ?.firstOrNull { it.calleeExpression?.textOffset == offset } + if (story != null) return analyze(file) { + StoryGutterMarkers(this, file).checkpointsOf(story).map { checkpoint -> + GutterNavigationTarget(file.text.substring(checkpoint).lineSequence().first().trim(), DefinitionLocation(path, checkpoint)) + } + } val owner = file.findElementAt(offset)?.parentsWithSelf?.firstOrNull { element -> (element is KtPropertyAccessor || element is KtCallableDeclaration) && element.textOffset == offset } ?: return emptyList() @@ -151,5 +166,8 @@ internal fun PsiElement.fold(placeholder: String): CodeFold? = private val AttachClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.Attach")) -private val StateClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.State")) +private val StoryPackage = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") +private val StateClassId = ClassId(StoryPackage, Name.identifier("State")) +private val ActionClassId = ClassId(StoryPackage, Name.identifier("Action")) + private val EventClassId = ClassId.topLevel(FqName("ru.hollowhorizon.hollowengine.common.events.SubscribeEvent")) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt new file mode 100644 index 000000000..9efb64f44 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/structure/StoryGutterMarkers.kt @@ -0,0 +1,141 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.structure + +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall +import org.jetbrains.kotlin.analysis.api.resolution.KaSuccessCallInfo +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import org.jetbrains.kotlin.psi.psiUtil.parents +import ru.hollowhorizon.hollowengine.client.HighlightTheme +import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarker +import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarkerKind + +/** + * The gutter markers of story code: stage and action calls, after which a restart continues, and checkpoints with the + * calls that refer to them. Each checkpoint name of the file gets its own color, in the order the names first appear, + * and `rewind` and `temporary` get the color of the checkpoint they lead to, so a call that names none still shows + * which one it means. + */ +internal class StoryGutterMarkers(private val session: KaSession, file: KtFile) { + private class Checkpoint(val owner: KtFunction?, val offset: Int, val name: String) + + private val checkpoints: List = file.collectDescendantsOfType() + .filter { it.calleeExpression?.text == CheckpointName.asString() && isStory(it, CheckpointName) } + .mapNotNull { call -> literal(call.valueArguments.firstOrNull())?.let { Checkpoint(owner(call), call.textOffset, it) } } + + private val colors = LinkedHashMap().apply { checkpoints.forEach { getOrPut(it.name) { size } } } + + /** The marker of [call] at [expression], at offset 0 for the caller to place, or null when it is no story call. */ + fun markerFor(expression: KtExpression, call: KaFunctionCall<*>): GutterMarker? = with(session) { + val symbol = call.symbol as? KaNamedFunctionSymbol ?: return null + val callExpression = expression as? KtCallExpression + if (symbol.callableId?.packageName == StoryPackage) when (symbol.name) { + CheckpointName -> { + val name = literal(callExpression?.valueArguments?.firstOrNull()) ?: "?" + return GutterMarker(0, GutterMarkerKind.CHECKPOINT, listOf(name), color = color(name)) + } + + RewindName -> return rewindTarget(callExpression, expression) + ?.let { GutterMarker(0, GutterMarkerKind.REWIND, listOf(it), color = color(it)) } + ?: GutterMarker(0, GutterMarkerKind.REWIND_START) + + TemporaryName -> return temporaryTarget(callExpression, expression) + ?.let { GutterMarker(0, GutterMarkerKind.TEMPORARY, listOf(it), color = color(it)) } + ?: GutterMarker(0, GutterMarkerKind.TEMPORARY_OPEN) + } + if (StateClassId in symbol.annotations) return GutterMarker(0, GutterMarkerKind.STAGE, listOf(symbol.name.asString())) + if ((ActionClassId in symbol.annotations || ActionAliasClassId in symbol.annotations) && isBoundary(expression)) { + return GutterMarker(0, GutterMarkerKind.ACTION_CALL, listOf(symbol.name.asString())) + } + val receiver = call.partiallyAppliedSymbol.dispatchReceiver?.type + if (symbol.name.asString() == "invoke" && receiver != null && StateClassId in receiver.annotations) { + return GutterMarker(0, GutterMarkerKind.STAGE, listOf(callExpression?.calleeExpression?.text ?: "invoke")) + } + return null + } + + /** + * The offsets of the checkpoints the `rewind` or `temporary` call [call] leads to: those of the code it is written + * in first, then the checkpoints of the same name elsewhere in the file, which another run of the story may pass. + */ + fun checkpointsOf(call: KtCallExpression): List { + val target = when { + isStory(call, RewindName) -> rewindTarget(call, call) + isStory(call, TemporaryName) -> temporaryTarget(call, call) + else -> null + } ?: return emptyList() + val owner = owner(call) + return checkpoints.filter { it.name == target }.sortedBy { if (it.owner == owner) 0 else 1 }.map { it.offset } + } + + /** The checkpoint a `rewind` goes to: the one it names, or the last one before it in the same code. */ + private fun rewindTarget(call: KtCallExpression?, expression: KtExpression): String? = argument(call, "checkpoint", 0) + ?: checkpoints.lastOrNull { it.owner == owner(expression) && it.offset < expression.textOffset }?.name + + /** The checkpoint a `temporary` stands until: the one it names, or the next one after it in the same code. */ + private fun temporaryTarget(call: KtCallExpression?, expression: KtExpression): String? = argument(call, "until", null) + ?: checkpoints.firstOrNull { it.owner == owner(expression) && it.offset > expression.textOffset }?.name + + private fun color(name: String): Int = HighlightTheme.paletteArgb(colors.getOrPut(name) { colors.size }) + + /** + * Whether an action called at [expression] is a boundary: it is written in a stage or `@State` lambda itself, not + * in a lambda inside it or in `catch` and `finally`, where it is an ordinary call. + */ + private fun isBoundary(expression: KtExpression): Boolean = with(session) { + val function = expression.parents.filterIsInstance().firstOrNull() ?: return false + val stage = when (function) { + is KtNamedFunction -> StateClassId in function.symbol.annotations + is KtFunctionLiteral -> isStageLambda(function) + else -> false + } + stage && expression.parents.takeWhile { it != function }.none { it is KtCatchClause || it is KtFinallySection } + } + + /** The code a checkpoint belongs to: the function or `@State` lambda it is written in, which is one run's. */ + private fun owner(element: PsiElement): KtFunction? = element.parents.filterIsInstance() + .firstOrNull { it is KtNamedFunction || it is KtFunctionLiteral && isStageLambda(it) } + + private fun isStageLambda(literal: KtFunctionLiteral): Boolean = with(session) { + val lambda = literal.parent as? KtLambdaExpression ?: return false + val call = lambda.getParentOfType(true) ?: return false + val resolved = (call.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*> ?: return false + val parameter = resolved.argumentMapping[lambda] ?: return false + StateClassId in parameter.returnType.annotations + } + + private fun isStory(call: KtCallExpression, name: Name): Boolean = with(session) { + val symbol = ((call.resolveToCall() as? KaSuccessCallInfo)?.call as? KaFunctionCall<*>)?.symbol as? KaNamedFunctionSymbol + symbol?.callableId?.packageName == StoryPackage && symbol.name == name + } + + /** The constant string passed for [parameter], by name or, when [position] is given, in that place. */ + private fun argument(call: KtCallExpression?, parameter: String, position: Int?): String? { + val arguments = call?.valueArguments ?: return null + val argument = arguments.firstOrNull { it.getArgumentName()?.asName?.asString() == parameter } + ?: position?.let { arguments.getOrNull(it)?.takeIf { argument -> argument.getArgumentName() == null } } + return literal(argument) + } + + private fun literal(argument: ValueArgument?): String? { + val template = argument?.getArgumentExpression() as? KtStringTemplateExpression ?: return null + return (template.entries.singleOrNull() as? KtLiteralStringTemplateEntry)?.text + } + + private companion object { + val StoryPackage = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") + val StateClassId = ClassId(StoryPackage, Name.identifier("State")) + val ActionClassId = ClassId(StoryPackage, Name.identifier("Action")) + val ActionAliasClassId = ClassId(StoryPackage, Name.identifier("ActionAlias")) + val CheckpointName = Name.identifier("checkpoint") + val RewindName = Name.identifier("rewind") + val TemporaryName = Name.identifier("temporary") + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt index 246b3d944..2d8e15d32 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/HollowEngineCompilerPlugin.kt @@ -4,6 +4,8 @@ import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter +import ru.hollowhorizon.hollowengine.common.plugin.fir.stages.StageFirRegistrar import ru.hollowhorizon.hollowengine.common.plugin.ir.StateIrGenerationExtension import ru.hollowhorizon.hollowengine.common.plugin.mixins.MixinDeclarationCollector import ru.hollowhorizon.hollowengine.common.plugin.mixins.MixinIrGenerationExtension @@ -14,6 +16,7 @@ class HollowEngineCompilerPlugin : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + FirExtensionRegistrarAdapter.registerExtension(StageFirRegistrar()) IrGenerationExtension.registerExtension(StateIrGenerationExtension()) val mixins = configuration.get(MixinDeclarationCollector.KEY) ?: MixinDeclarationCollector() IrGenerationExtension.registerExtension(MixinIrGenerationExtension(mixins)) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt new file mode 100644 index 000000000..49d13bd71 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirActions.kt @@ -0,0 +1,76 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.declarations.* +import org.jetbrains.kotlin.fir.declarations.utils.isSuspend +import org.jetbrains.kotlin.fir.expressions.FirFunctionCall +import org.jetbrains.kotlin.fir.references.toResolvedCallableSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.types.classId +import org.jetbrains.kotlin.fir.types.coneType +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** Action arguments are persisted too; report their restrictions through the same front end as stages. */ +@OptIn(SymbolInternals::class) +internal object StageFirActions { + context(context: CheckerContext, reporter: DiagnosticReporter) + fun check(function: FirNamedFunction) { + if (!function.hasAnnotation(StageFirIds.ACTION, context.session)) return + val owner = context.containingDeclarations.lastOrNull()?.fir + val problem = when { + !function.isSuspend -> "An action must be a suspend function" + owner !is FirScript && (owner as? FirRegularClass)?.classKind != ClassKind.OBJECT -> "An action must be declared at the top level of a script or in an object" + function.typeParameters.isNotEmpty() -> "An action cannot have type parameters: the journal saves its arguments by their declared types" + function.contextParameters.isNotEmpty() -> "An action cannot take context parameters" + else -> null + } + if (problem != null) reporter.reportOn(function.source, StageDiagnostics.STAGE_ERROR, problem) + function.valueParameters.forEach { parameter -> + if (parameter.hasAnnotation(StageFirIds.UNSAVED, context.session)) { + if (parameter.defaultValue == null) reporter.reportOn( + parameter.source, + StageDiagnostics.STAGE_ERROR, + "The journal calls the action with the default of the unsaved '${parameter.name}', so it needs one" + ) + } else if (parameter.returnTypeRef.coneType.classId != StageFirIds.CHECKPOINT_SCOPE) { + parameter.returnTypeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn( + parameter.source, + StageDiagnostics.STAGE_ERROR, + "The journal saves the arguments of an action, but '${parameter.name}' cannot be saved: $it; mark it @Unsaved if undoing does not need it" + ) + } + } + } + function.receiverParameter?.let { receiver -> + if (receiver.typeRef.coneType.classId != StageFirIds.CHECKPOINT_SCOPE) { + receiver.typeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn( + receiver.source, + StageDiagnostics.STAGE_ERROR, + "The journal saves the arguments of an action, but 'this' cannot be saved: $it" + ) + } + } + } + } + + context(context: CheckerContext, reporter: DiagnosticReporter) + fun checkCall(call: FirFunctionCall) { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return + if (symbol.callableId?.packageName != StageFirIds.PACKAGE || symbol.name.asString() !in setOf( + "action", + "label" + ) + ) return + val owner = context.containingDeclarations.lastOrNull { it.fir is FirFunction }?.fir as? FirFunction + if (owner?.hasAnnotation(StageFirIds.ACTION, context.session) != true) reporter.reportOn( + call.source, + StageDiagnostics.STAGE_ERROR, + "action { } and label { } must be the body of an @Action function, written directly in it" + ) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt new file mode 100644 index 000000000..45f082b9f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirBodyChecker.kt @@ -0,0 +1,135 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction +import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess +import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.declarations.FirNamedFunction +import org.jetbrains.kotlin.fir.declarations.FirScript +import org.jetbrains.kotlin.fir.declarations.getAnnotationByClassId +import org.jetbrains.kotlin.fir.declarations.getStringArgument +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.expressions.* +import org.jetbrains.kotlin.fir.expressions.impl.FirResolvedArgumentList +import org.jetbrains.kotlin.fir.references.toResolvedCallableSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.types.coneType +import org.jetbrains.kotlin.fir.types.resolvedType +import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +/** Structural restrictions are checked on FIR so the editor can report them before the IR rewrite. */ +@OptIn(SymbolInternals::class) +internal object StageFirBodyChecker { + /** The names of the functions of the script being checked, the names their `@State` gives them included. */ + @OptIn(DirectDeclarationsAccess::class) + context(context: CheckerContext) + private fun functionNames(): Set { + val script = context.containingDeclarations.map { it.fir }.filterIsInstance().firstOrNull() ?: return emptySet() + return script.declarations.filterIsInstance().flatMapTo(HashSet()) { function -> + listOfNotNull( + function.name.asString(), + function.getAnnotationByClassId(StageFirIds.STATE, context.session)?.getStringArgument(Name.identifier("name")), + ) + } + } + + context(context: CheckerContext, reporter: DiagnosticReporter) + fun check(function: FirFunction, stage: Boolean) { + val stageLambdas = HashSet() + function.body?.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirFunctionCall) { + (element.argumentList as? FirResolvedArgumentList)?.mapping?.forEach { (argument, parameter) -> + if (parameter.returnTypeRef.coneType.isStageLambda(context.session)) { + (argument as? FirAnonymousFunctionExpression)?.anonymousFunction?.let(stageLambdas::add) + } + } + } + element.acceptChildren(this) + } + }) + val checkpoints = HashSet() + function.body?.accept(object : FirVisitorVoid() { + private var nested = 0 + private var handler = 0 + + override fun visitElement(element: FirElement) { + when (element) { + is FirAnonymousFunction if element in stageLambdas -> return + is FirFunction -> { + if (!stage) return // Named local functions receive their own declaration check. + nested++ + element.acceptChildren(this) + nested-- + } + + is FirCatch -> { + handler++ + element.acceptChildren(this) + handler-- + } + + is FirTryExpression -> { + element.tryBlock.accept(this) + element.catches.forEach { it.accept(this) } + handler++ + element.finallyBlock?.accept(this) + handler-- + } + + is FirFunctionCall -> { + checkCall(element) + element.acceptChildren(this) + } + + else -> element.acceptChildren(this) + } + } + + private fun checkCall(call: FirFunctionCall) { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return + val checkpoint = + symbol.callableId?.packageName == StageFirIds.PACKAGE && symbol.name.asString() == "checkpoint" + val boundary = checkpoint || symbol.hasAnnotation( + StageFirIds.STATE, + context.session + ) || symbol.name.asString() == "invoke" && call.dispatchReceiver?.resolvedType?.isStageLambda(context.session) == true + if (!boundary) return + val problem = when { + !stage && checkpoint -> StageMessages.CHECKPOINT_OUTSIDE + !stage -> null + nested > 0 -> StageMessages.NESTED_CALL + handler > 0 -> StageMessages.HANDLER_CALL + else -> null + } + if (problem != null) reporter.reportOn(call.source, StageDiagnostics.STAGE_ERROR, problem) + if (!stage || !checkpoint) return + val name = + ((call.argumentList as? FirResolvedArgumentList)?.mapping?.entries?.firstOrNull { it.value.name.asString() == "name" }?.key as? FirLiteralExpression)?.value as? String + when { + name.isNullOrBlank() -> reporter.reportOn( + call.source, + StageDiagnostics.STAGE_ERROR, + "A checkpoint needs a constant name" + ) + + !checkpoints.add(name) -> reporter.reportOn( + call.source, StageDiagnostics.STAGE_ERROR, "Checkpoint '$name' appears twice in this stage" + ) + + name in functionNames() -> reporter.reportOn( + call.source, + StageDiagnostics.STAGE_ERROR, + "A checkpoint cannot be named '$name': the script already has a function with this name, and rewind(\"$name\") would read as either" + ) + } + } + }) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt new file mode 100644 index 000000000..8a31d7f0b --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirCallChecker.kt @@ -0,0 +1,273 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.analysis.checkers.expression.FirFunctionCallChecker +import org.jetbrains.kotlin.fir.declarations.FirAnonymousFunction +import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirFile +import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.declarations.FirNamedFunction +import org.jetbrains.kotlin.fir.declarations.FirProperty +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.declarations.FirScript +import org.jetbrains.kotlin.fir.expressions.FirAnonymousFunctionExpression +import org.jetbrains.kotlin.fir.expressions.FirCallableReferenceAccess +import org.jetbrains.kotlin.fir.expressions.FirFunctionCall +import org.jetbrains.kotlin.fir.expressions.FirLiteralExpression +import org.jetbrains.kotlin.fir.expressions.FirQualifiedAccessExpression +import org.jetbrains.kotlin.fir.expressions.FirVariableAssignment +import org.jetbrains.kotlin.fir.expressions.impl.FirResolvedArgumentList +import org.jetbrains.kotlin.fir.references.toResolvedCallableSymbol +import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol +import org.jetbrains.kotlin.fir.symbols.FirBasedSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirFunctionSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirLocalPropertySymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol +import org.jetbrains.kotlin.fir.types.coneType +import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +@OptIn(SymbolInternals::class) +internal object StageFirCallChecker : FirFunctionCallChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(expression: FirFunctionCall) { + if (context.containingDeclarations.none { it.fir is FirScript }) return + StageFirActions.checkCall(expression) + checkCheckpointName(expression) + val mapping = (expression.argumentList as? FirResolvedArgumentList)?.mapping ?: return + val startsStory = startsStory(expression) + mapping.forEach { (argument, parameter) -> + if (!parameter.returnTypeRef.coneType.isStageLambda(context.session)) return@forEach + val lambda = (argument as? FirAnonymousFunctionExpression)?.anonymousFunction + if (lambda != null) { + val arity = + lambda.valueParameters.size + lambda.contextParameters.size + if (lambda.receiverParameter != null) 1 else 0 + if (arity > 8) reporter.reportOn( + argument.source, + StageDiagnostics.STAGE_ERROR, + "A @State lambda takes at most 8 parameters, its receiver included" + ) + StageFirSignatures.check(lambda) + checkCaptures(lambda, startsStory) + StageFirBodyChecker.check(lambda, stage = true) + } else { + val forwarded = + (argument as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() as? FirValueParameterSymbol + if (forwarded == null || !forwarded.resolvedReturnType.isStageLambda(context.session)) { + reporter.reportOn(argument.source, StageDiagnostics.STAGE_ERROR, StageMessages.FUNCTION_VALUE) + } + } + } + } + + /** + * Warns about `rewind("x")` and `temporary(until = "x")` naming no checkpoint of the script. It is no error: + * another script whose stages the story calls may pass one under that name. + */ + context(context: CheckerContext, reporter: DiagnosticReporter) + private fun checkCheckpointName(call: FirFunctionCall) { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return + if (symbol.callableId?.packageName != StageFirIds.PACKAGE) return + val parameter = when (symbol.name.asString()) { + "rewind" -> "checkpoint" + "temporary" -> "until" + else -> return + } + val argument = (call.argumentList as? FirResolvedArgumentList)?.mapping?.entries + ?.firstOrNull { it.value.name.asString() == parameter }?.key as? FirLiteralExpression ?: return + val name = argument.value as? String ?: return + val file = context.containingDeclarations.map { it.fir }.filterIsInstance().firstOrNull() ?: return + if (name in checkpointNames(file)) return + reporter.reportOn( + argument.source, + StageDiagnostics.STAGE_WARNING, + "No checkpoint of this script is named '$name': it only works if another script the story runs passes one" + ) + } + + /** The constant names the checkpoints of [file] are given. */ + private fun checkpointNames(file: FirFile): Set { + val names = HashSet() + file.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirFunctionCall) { + val symbol = element.calleeReference.toResolvedCallableSymbol() + if (symbol?.callableId?.packageName == StageFirIds.PACKAGE && symbol.name.asString() == "checkpoint") { + ((element.argumentList.arguments.firstOrNull() as? FirLiteralExpression)?.value as? String)?.let(names::add) + } + } + element.acceptChildren(this) + } + }) + return names + } + + /** `launchStory` and `StoryRuns.launch` start a story of their own, which keeps copies of what it captures. */ + private fun startsStory(call: FirFunctionCall): Boolean { + val symbol = call.calleeReference.toResolvedCallableSymbol() ?: return false + val name = symbol.name.asString() + return (name == "launchStory" || name == "launch") && symbol.callableId?.packageName == StageFirIds.PACKAGE + } + + /** + * Whether the var [symbol] can be shared with a `@State` lambda: a `@State` function or lambda declares it + * directly, so the compiler can turn it into a box the stage and the lambda share. + */ + context(context: CheckerContext) + private fun isShareable(symbol: FirLocalPropertySymbol): Boolean { + val owner = context.containingDeclarations.asReversed().map { it.fir }.filterIsInstance() + .firstOrNull { it.declaresDirectly(symbol) } ?: return false + return when (owner) { + is FirNamedFunction -> owner.hasAnnotation(StageFirIds.STATE, context.session) + is FirAnonymousFunction -> context.callsOrAssignments.filterIsInstance().any { call -> + (call.argumentList as? FirResolvedArgumentList)?.mapping?.any { (argument, parameter) -> + (argument as? FirAnonymousFunctionExpression)?.anonymousFunction === owner && + parameter.returnTypeRef.coneType.isStageLambda(context.session) + } == true + } + else -> false + } + } + + private fun FirFunction.declaresDirectly(symbol: FirLocalPropertySymbol): Boolean { + var found = false + body?.acceptChildren(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (found || element is FirFunction) return + if (element is FirProperty && element.symbol == symbol) found = true else element.acceptChildren(this) + } + }) + return found + } + + /** + * Why the local function [function] cannot be called from a `@State` lambda, or null when it can: the compiler + * copies it out of its scope, so what it uses from there must be saved with the lambda, like a capture. + */ + context(context: CheckerContext) + private fun localFunctionProblem(function: FirFunction, startsStory: Boolean, visited: MutableSet): String? { + if (!visited.add(function)) return null + if (function.typeParameters.isNotEmpty()) return "it has type parameters" + val owned = HashSet>() + function.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirDeclaration) owned += element.symbol + element.acceptChildren(this) + } + }) + var problem: String? = null + function.body?.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (problem != null) return + problem = when (element) { + is FirQualifiedAccessExpression -> access(element) + is FirVariableAssignment -> assignment(element) + else -> null + } + element.acceptChildren(this) + } + + private fun access(expression: FirQualifiedAccessExpression): String? { + val symbol = expression.calleeReference.toResolvedCallableSymbol() ?: return null + if (symbol in owned) return null + val name = symbol.name.asString() + return when { + symbol is FirConstructorSymbol && symbol.resolvedReturnType.toRegularClassSymbol(context.session)?.fir?.isLocal == true -> + "it makes local class '${symbol.callableId.classId?.shortClassName}'" + + symbol is FirFunctionSymbol<*> && symbol.fir.isLocal -> + if (expression is FirCallableReferenceAccess) "it refers to local function '$name'" + else localFunctionProblem(symbol.fir, startsStory, visited) + + symbol is FirLocalPropertySymbol && symbol.fir.isVar -> when { + !isShareable(symbol) -> "it uses 'var $name', which no @State function or lambda declares directly" + startsStory -> "it uses 'var $name', and a story started here keeps copies of what it captures" + else -> symbol.resolvedReturnType.stageProblem(context.session)?.let { "it uses '$name', which cannot be saved: $it" } + } + + symbol is FirLocalPropertySymbol || symbol is FirValueParameterSymbol -> + symbol.resolvedReturnType.stageProblem(context.session)?.let { "it uses '$name', which cannot be saved: $it" } + + else -> null + } + } + + private fun assignment(assignment: FirVariableAssignment): String? { + val symbol = (assignment.lValue as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() + if (symbol !is FirLocalPropertySymbol || symbol in owned || (symbol.fir.isVar && isShareable(symbol))) return null + return "it assigns '${symbol.name}', which it can only read" + } + }) + return problem + } + + context(context: CheckerContext, reporter: DiagnosticReporter) + private fun checkCaptures(lambda: FirAnonymousFunction, startsStory: Boolean) { + val owned = HashSet>() + lambda.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + if (element is FirDeclaration) owned += element.symbol + element.acceptChildren(this) + } + }) + lambda.body?.accept(object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + when (element) { + is FirQualifiedAccessExpression -> checkAccess(element) + is FirVariableAssignment -> checkAssignment(element) + } + element.acceptChildren(this) + } + + private fun checkAccess(qualifiedAccessExpression: FirQualifiedAccessExpression) { + val symbol = qualifiedAccessExpression.calleeReference.toResolvedCallableSymbol() ?: return + if (symbol in owned) return + val name = symbol.name.asString() + val message = when { + symbol is FirConstructorSymbol && symbol.resolvedReturnType.toRegularClassSymbol(context.session)?.fir?.isLocal == true -> StageMessages.localClosure( + "local class '${symbol.callableId.classId?.shortClassName}'" + ) + + symbol is FirFunctionSymbol<*> && symbol.fir.isLocal -> + if (qualifiedAccessExpression is FirCallableReferenceAccess) StageMessages.localClosure("local function '$name'") + else localFunctionProblem(symbol.fir, startsStory, HashSet())?.let { StageMessages.localFunction(name, it) } + + symbol is FirLocalPropertySymbol && symbol.fir.isVar -> when { + !isShareable(symbol) -> StageMessages.mutableCapture(name) + startsStory -> StageMessages.sharedVar(name, "a story started here keeps copies of what it captures; pass the value instead") + symbol.fir.initializer == null -> StageMessages.sharedVar(name, "give it a value where it is declared") + else -> symbol.resolvedReturnType.stageProblem(context.session)?.let { StageMessages.sharedVar(name, "its value is saved, but $it") } + } + + symbol is FirLocalPropertySymbol || symbol is FirValueParameterSymbol -> symbol.resolvedReturnType.stageProblem( + context.session + )?.let { StageMessages.unsavedCapture(name, it) } + + else -> null + } + if (message != null) reporter.reportOn( + qualifiedAccessExpression.source, StageDiagnostics.STAGE_ERROR, message + ) + } + + private fun checkAssignment(variableAssignment: FirVariableAssignment) { + val symbol = + (variableAssignment.lValue as? FirQualifiedAccessExpression)?.calleeReference?.toResolvedCallableSymbol() + if (symbol != null && symbol !in owned && symbol is FirLocalPropertySymbol && !(symbol.fir.isVar && isShareable(symbol))) { + reporter.reportOn( + variableAssignment.source, + StageDiagnostics.STAGE_ERROR, + StageMessages.assignCapture(symbol.name.asString()) + ) + } + } + }) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt new file mode 100644 index 000000000..8670d0ebb --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirExtension.kt @@ -0,0 +1,26 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.analysis.checkers.declaration.DeclarationCheckers +import org.jetbrains.kotlin.fir.analysis.checkers.expression.ExpressionCheckers +import org.jetbrains.kotlin.fir.analysis.extensions.FirAdditionalCheckersExtension +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** The same checkers run during compilation and standalone IDE analysis, without generating bytecode. */ +internal class StageFirRegistrar : FirExtensionRegistrar() { + override fun ExtensionRegistrarContext.configurePlugin() { + +::StageFirExtension + registerDiagnosticContainers(StageDiagnostics) + } +} + +private class StageFirExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { + override val expressionCheckers = object : ExpressionCheckers() { + override val functionCallCheckers = setOf(StageFirCallChecker) + } + + override val declarationCheckers = object : DeclarationCheckers() { + override val simpleFunctionCheckers = setOf(StageFirFunctionChecker) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt new file mode 100644 index 000000000..a3d565bb4 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirFunctionChecker.kt @@ -0,0 +1,47 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.analysis.checkers.MppCheckerKind +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.analysis.checkers.declaration.FirSimpleFunctionChecker +import org.jetbrains.kotlin.fir.declarations.* +import org.jetbrains.kotlin.fir.declarations.utils.isSuspend +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +@OptIn(SymbolInternals::class, DirectDeclarationsAccess::class) +internal object StageFirFunctionChecker : FirSimpleFunctionChecker(MppCheckerKind.Common) { + context(context: CheckerContext, reporter: DiagnosticReporter) + override fun check(declaration: FirNamedFunction) { + if (context.containingDeclarations.none { it.fir is FirScript }) return + StageFirActions.check(declaration) + val stage = declaration.hasAnnotation(StageFirIds.STATE, context.session) + StageFirBodyChecker.check(declaration, stage) + if (!stage) return + val owner = context.containingDeclarations.lastOrNull()?.fir as? FirScript + fun stageName(function: FirNamedFunction): String = + function.getAnnotationByClassId(StageFirIds.STATE, context.session) + ?.getStringArgument(Name.identifier("name"))?.takeIf(String::isNotBlank) ?: function.name.asString() + + val name = stageName(declaration) + val first = owner?.declarations?.filterIsInstance()?.firstOrNull { + it.hasAnnotation(StageFirIds.STATE, context.session) && stageName(it) == name + } + if (first != null && first !== declaration) reporter.reportOn( + declaration.source, + StageDiagnostics.STAGE_ERROR, + "Two stages of this script are named '$name'; give one of them different @State(name = ...)" + ) + val problem = when { + context.containingDeclarations.lastOrNull()?.fir !is FirScript -> StageMessages.TOP_LEVEL + !declaration.isSuspend -> StageMessages.SUSPEND + declaration.typeParameters.isNotEmpty() -> StageMessages.TYPE_PARAMETERS + else -> null + } + if (problem != null) reporter.reportOn(declaration.source, StageDiagnostics.STAGE_ERROR, problem) + StageFirSignatures.check(declaration) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt new file mode 100644 index 000000000..3f335db6e --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirSignatures.kt @@ -0,0 +1,32 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.diagnostics.DiagnosticReporter +import org.jetbrains.kotlin.diagnostics.reportOn +import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext +import org.jetbrains.kotlin.fir.declarations.FirFunction +import org.jetbrains.kotlin.fir.types.coneType +import org.jetbrains.kotlin.fir.types.isUnit +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** Named stages and lifted lambdas persist the same arguments and result. */ +internal object StageFirSignatures { + context(context: CheckerContext, reporter: DiagnosticReporter) + fun check(function: FirFunction) { + (function.contextParameters + function.valueParameters).forEach { parameter -> + parameter.returnTypeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn(parameter.source, StageDiagnostics.STAGE_ERROR, + "The arguments of a stage are saved, but '${parameter.name}' cannot be: $it") + } + } + function.receiverParameter?.let { receiver -> + receiver.typeRef.coneType.stageProblem(context.session)?.let { + reporter.reportOn(receiver.source, StageDiagnostics.STAGE_ERROR, + "The arguments of a stage are saved, but 'this' cannot be: $it") + } + } + val result = function.returnTypeRef.coneType + if (!result.isUnit) result.stageProblem(context.session)?.let { + reporter.reportOn(function.source, StageDiagnostics.STAGE_ERROR, "The result of a stage is saved, but $it") + } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt new file mode 100644 index 000000000..624c7ee24 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/fir/stages/StageFirTypes.kt @@ -0,0 +1,58 @@ +package ru.hollowhorizon.hollowengine.common.plugin.fir.stages + +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe +import org.jetbrains.kotlin.fir.resolve.toRegularClassSymbol +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.types.ConeErrorType +import org.jetbrains.kotlin.fir.types.ConeKotlinType +import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjection +import org.jetbrains.kotlin.fir.types.classId +import org.jetbrains.kotlin.fir.types.customAnnotations +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageValueTypes + +internal object StageFirIds { + val PACKAGE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") + val STATE = ClassId.topLevel(PACKAGE.child(Name.identifier("State"))) + val ACTION = ClassId.topLevel(PACKAGE.child(Name.identifier("Action"))) + val UNSAVED = ClassId.topLevel(PACKAGE.child(Name.identifier("Unsaved"))) + val CHECKPOINT_SCOPE = ClassId.topLevel(PACKAGE.child(Name.identifier("CheckpointScope"))) +} + +internal fun ConeKotlinType.isStageLambda(session: FirSession): Boolean = + customAnnotations.any { it.toAnnotationClassIdSafe(session) == StageFirIds.STATE } || + classId?.asSingleFqName()?.asString().orEmpty().startsWith(StageValueTypes.STAGE_LAMBDA) + +/** Type policy agrees with the IR checker; error types are left to Kotlin's own diagnostics. */ +@OptIn(SymbolInternals::class) +internal fun ConeKotlinType.stageProblem(session: FirSession): String? { + if (this is ConeErrorType || isStageLambda(session)) return null + val klass = toRegularClassSymbol(session) ?: return "$this is a type parameter, which cannot be saved" + val name = klass.classId.asSingleFqName().asString() + if (name in StageValueTypes.BASIC || name in StageValueTypes.PRIMITIVE_ARRAYS || name == StageValueTypes.STORY_HANDLE) return null + fun inherits(type: ConeKotlinType, visited: MutableSet = HashSet()): Boolean { + val symbol = type.toRegularClassSymbol(session) ?: return false + if (!visited.add(symbol.classId)) return false + val qualified = symbol.classId.asSingleFqName().asString() + if (qualified == "net.minecraft.world.entity.Entity" || qualified == "net.minecraft.nbt.Tag") return true + return symbol.resolvedSuperTypes.any { inherits(it, visited) } + } + if (inherits(this)) return null + if (name == "kotlin.Array" || name in StageValueTypes.CONTAINERS || name in StageValueTypes.CONTEXTUAL || + klass.classKind == ClassKind.ENUM_CLASS || klass.hasAnnotation(StageValueTypes.SERIALIZABLE_ID, session) + ) { + return typeArguments.firstNotNullOfOrNull { + (it as? ConeKotlinTypeProjection)?.type?.stageProblem(session) ?: if (it is ConeKotlinTypeProjection) null + else "a star projection cannot be saved" + } + } + if (name.startsWith("kotlin.Function") || name.startsWith("kotlin.coroutines.SuspendFunction") || name.startsWith("kotlin.reflect.KFunction")) { + return "a function value cannot be saved; pass a lambda to a @State parameter instead" + } + return "$this cannot be saved: it is not @Serializable" +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt index 32feba5fd..8cfe05510 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/StateIrGenerationExtension.kt @@ -3,15 +3,15 @@ package ru.hollowhorizon.hollowengine.common.plugin.ir import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import ru.hollowhorizon.hollowengine.common.plugin.ir.stages.StageIrGeneration import ru.hollowhorizon.hollowengine.common.plugin.ir.transformers.ImportedScriptReceiverTransformer -import ru.hollowhorizon.hollowengine.common.plugin.ir.transformers.StateCallTransformer class StateIrGenerationExtension : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { Companion.pluginContext = pluginContext moduleFragment.transform(ImportedScriptReceiverTransformer(moduleFragment, pluginContext), null) - moduleFragment.transform(StateCallTransformer(), null) + StageIrGeneration(pluginContext).generate(moduleFragment) } companion object { diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt new file mode 100644 index 000000000..48620f8dd --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageActions.kt @@ -0,0 +1,111 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irVararg +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrVararg +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.util.isAnnotation +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid + +/** + * Compiles the `@Action` functions of a script. The journal calls an action again to undo it, so it saves the + * receivers and parameters of every call: they must be serializable, and `action { }` or `label { }` gets them + * passed here, which a script cannot get wrong. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageActions(private val stages: StageScope) { + private val reporter = stages.reporter + + fun compile() { + stages.script.acceptChildrenVoid(object : IrVisitorVoid() { + private val functions = ArrayDeque() + private val bodies = HashMap() + + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + val action = isAction(declaration) + if (action) check(declaration as IrSimpleFunction) + functions.addLast(declaration) + super.visitFunction(declaration) + functions.removeLast() + if (action && bodies[declaration] == null) reporter.error( + declaration, + "The body of an @Action is action { } or label { }, which the journal calls it again through" + ) + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + if (!stages.symbols.isActionBody(expression.symbol.owner)) return + val function = functions.lastOrNull() + if (function == null || !isAction(function)) { + reporter.error( + expression, + "action { } and label { } must be the body of an @Action function, written directly in it" + ) + return + } + bodies.merge(function, 1, Int::plus) + pass(function, expression) + } + }) + } + + /** Passes [call] every parameter of [function], receivers first, unless the script passed some itself. */ + private fun pass(function: IrFunction, call: IrCall) { + val given = call.arguments[0] as? IrVararg + if (given != null && given.elements.isNotEmpty()) { + reporter.error( + call, + "The compiler passes the receivers and parameters of the action to action { }; leave its parentheses empty" + ) + return + } + val builder = stages.builder(function.symbol, call) + val elementType = stages.context.irBuiltIns.anyNType + call.arguments[0] = builder.irVararg(elementType, function.parameters.map { builder.irGet(it) }) + } + + private fun check(function: IrSimpleFunction) { + val owner = function.parent + val problem = when { + !function.isSuspend -> "An action must be a suspend function" + owner != stages.script && (owner as? IrClass)?.kind != ClassKind.OBJECT -> "An action must be declared at the top level of a script or in an object" + + function.typeParameters.isNotEmpty() -> "An action cannot have type parameters: the journal saves its arguments by their declared types" + function.parameters.any { it.kind == IrParameterKind.Context } -> "An action cannot take context parameters" + else -> null + } + problem?.let { reporter.error(function, it); return } + function.parameters.filter { it.kind == IrParameterKind.ExtensionReceiver || it.kind == IrParameterKind.Regular } + .filter { it.type.classOrNull != stages.symbols.checkpointScope }.forEach { parameter -> + val name = stages.argumentName(function, parameter) + if (parameter.annotations.any { it.isAnnotation(StageSymbols.UNSAVED) }) { + if (parameter.defaultValue == null) reporter.error( + parameter, + "The journal calls the action with the default of the unsaved '$name', so it needs one" + ) + return@forEach + } + stages.types.problem(parameter.type)?.let { + reporter.error( + parameter, + "The journal saves the arguments of an action, but '$name' cannot be saved: $it; mark it @Unsaved if undoing does not need it" + ) + } + } + } + + private fun isAction(function: IrFunction): Boolean = + function.annotations.any { it.isAnnotation(StageSymbols.ACTION) } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt new file mode 100644 index 000000000..ecdba7872 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageIrGeneration.kt @@ -0,0 +1,225 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrTry +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.util.dump +import org.jetbrains.kotlin.ir.util.isAnnotation +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.acceptVoid +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +/** + * Compiles stages of every script in a module: marks script classes so the runtime can find their + * instances, compiles their actions, turns `@State` lambdas into stages, rewrites each stage body for saving and + * resuming, and generates entries, that runtime starts stages through. Does nothing without story runtime on the + * classpath. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageIrGeneration(private val context: IrPluginContext) { + private val symbols = StageSymbols.find(context) + private val types = StageTypes(context) + + fun generate(module: IrModuleFragment) { + val symbols = symbols ?: return + module.files.forEach { file -> + file.declarations.filterIsInstance().filter { it.origin == IrDeclarationOrigin.SCRIPT_CLASS } + .forEach { script -> + ScriptStages( + StageScope(context, symbols, types, StageReporter(context, file), script), file + ).compile() + } + } + } + + private class ScriptStages(private val stages: StageScope, private val file: IrFile) { + private val script = stages.script + private val reporter = stages.reporter + + /** The stages made from lambdas, with how many of their arguments are captured values. */ + private var captures: Map = emptyMap() + + fun compile() { + script.annotations += stages.scriptAnnotation(file.fileEntry.name) + StageActions(stages).compile() + StageVarBoxes(stages).compile() + val lifter = StageLambdaLifter(stages) + script.declarations.toList().forEach { it.transform(lifter, null) } + script.declarations += lifter.lifted.keys + script.declarations += lifter.locals.lifted + captures = lifter.lifted + + val functions = script.declarations.filterIsInstance().filter(stages::isSuspendState) + functions.groupBy(stages::stageName).filterValues { it.size > 1 }.forEach { (name, duplicates) -> + duplicates.drop(1).forEach { + reporter.error( + it, "Two stages of this script are named '$name'; give one of them different @State(name = ...)" + ) + } + } + functions.forEach(::compile) + checkMisplaced() + } + + private fun compile(function: IrSimpleFunction) { + if (!isValid(function)) return + val body = function.body as? IrBlockBody ?: return + val name = stages.stageName(function) + val hash = body.dump().hashCode() + if (!sitesArePlaced(function)) return + + val normalizer = StageNormalizer(stages, function) + val errors = errorCount() + val statements = normalizer.normalize(body.statements.toList()) + if (errorCount() != errors) return + val layout = StageLayout(stages, function).apply { layout(statements) } + layout.saved.forEach { variable -> + val site = layout.sites.first { variable in it.live } + stages.types.problem(variable.type)?.let { problem -> + if (variable in normalizer.loopCopies) { + reporter.error( + variable, + "A for loop that calls stages goes over a copy of its elements saved with the story, but $problem; loop over values a story can keep, such as ids, and look the rest up inside" + ) + return@forEach + } + val what = + if (variable.origin == IrDeclarationOrigin.IR_TEMPORARY_VARIABLE) "A value computed before" else "'${variable.name}', used after" + reporter.error( + variable, + "$what the ${if (site.kind == SiteKind.ACTION) "action" else "stage"} call '${site.name}' is saved there, but $problem; compute it after the call or make it serializable" + ) + } + } + layout.sites.filter { it.kind == SiteKind.ACTION && it.form != SiteForm.STATEMENT && !it.call.type.isUnit() }.forEach { site -> + stages.types.problem(site.call.type)?.let { problem -> + reporter.error( + site.call, + "The result of the action call '${site.name}' is saved for a restart after it, but $problem; use the result in a statement of its own or return a serializable value" + ) + } + } + if (errorCount() != errors) return + StageRewriter(stages, function, layout).apply { + rewrite(statements, name, hash) + script.declarations += entry(name) + captures[function]?.let { + script.declarations += lambdaCall(name, it) + script.declarations += stages.lambdaResult(function, name) + } + } + } + + private fun isValid(function: IrSimpleFunction): Boolean { + val problem = when { + function.parent != script -> "A stage must be declared at the top level of a script" + function.typeParameters.isNotEmpty() -> StageMessages.TYPE_PARAMETERS + else -> null + } + problem?.let { reporter.error(function, it); return false } + var valid = true + stages.arguments(function).forEach { parameter -> + stages.types.problem(parameter.type)?.let { + reporter.error( + parameter, + "The arguments of a stage are saved, but '${ + stages.argumentName( + function, + parameter + ) + }' cannot be: $it" + ) + valid = false + } + } + if (!function.returnType.isUnit()) stages.types.problem(function.returnType)?.let { + reporter.error(function, "The result of a stage is saved, but $it") + valid = false + } + return valid + } + + /** Stage calls inside lambdas, `catch` or `finally` of a stage cannot be resumed and are errors. */ + private fun sitesArePlaced(function: IrSimpleFunction): Boolean { + val errors = errorCount() + function.body?.acceptChildrenVoid(object : IrVisitorVoid() { + private var nested = 0 + private var handler = 0 + + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + nested++ + super.visitFunction(declaration) + nested-- + } + + override fun visitClass(declaration: IrClass) { + nested++ + super.visitClass(declaration) + nested-- + } + + override fun visitTry(aTry: IrTry) { + aTry.tryResult.acceptVoid(this) + handler++ + aTry.catches.forEach { it.acceptVoid(this) } + aTry.finallyExpression?.acceptVoid(this) + handler-- + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + // An action elsewhere in a stage is an ordinary call: only one written in the stage itself is a boundary. + val kind = stages.siteKind(expression) ?: return + if (kind == SiteKind.ACTION) return + when { + nested > 0 -> reporter.error( + expression, + StageMessages.NESTED_CALL + ) + + handler > 0 -> reporter.error( + expression, + StageMessages.HANDLER_CALL + ) + } + } + }) + return errorCount() == errors + } + + /** Whatever is left after compiling the stages: checkpoints and `@State` where they cannot work. */ + private fun checkMisplaced() { + script.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + val marked = declaration.annotations.any { it.isAnnotation(StageSymbols.STATE) } + if (marked && declaration.parent != script) reporter.error( + declaration, StageMessages.TOP_LEVEL + ) + else if (marked && !stages.isSuspendState(declaration)) reporter.error( + declaration, StageMessages.SUSPEND + ) + super.visitFunction(declaration) + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + if (stages.symbols.isCheckpoint(expression.symbol.owner)) { + reporter.error(expression, StageMessages.CHECKPOINT_OUTSIDE) + } + } + }) + } + + private fun errorCount(): Int = reporter.errors + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt new file mode 100644 index 000000000..4b8667154 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaLifter.kt @@ -0,0 +1,278 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.IrGetValueImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.patchDeclarationParents +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages +import java.util.Collections +import java.util.IdentityHashMap + +/** + * Turns every lambda written for a `@State` function type into a stage of its own. + * + * The body moves into a new function of the script. What the lambda captured becomes its first parameters, and + * the parameters of the lambda, its receiver first, follow; the story saves them all like the arguments of any + * stage. In place of the lambda the call gets a `StageLambdaN` naming that function. The stage is named after + * where the lambda was written (`onJoin$launchStory#1`), so the name survives edits elsewhere in the script. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageLambdaLifter(private val stages: StageScope) : IrElementTransformerVoid() { + private val functions = ArrayDeque() + private val owners = ArrayDeque() + private val counters = HashMap() + + /** The functions made from lambdas, in the order they were made, with how many captured values they take. */ + val lifted = LinkedHashMap() + + /** The local functions lifted lambdas call, copied to the script. */ + val locals = StageLocalFunctions(stages) + + override fun visitProperty(declaration: IrProperty): IrStatement { + owners.addLast(declaration.name.asString()) + return super.visitProperty(declaration).also { owners.removeLast() } + } + + override fun visitFunction(declaration: IrFunction): IrStatement { + val named = + declaration.parent == stages.script && declaration is IrSimpleFunction && declaration.correspondingPropertySymbol == null + + if (named) owners.addLast(declaration.name.asString()) + functions.addLast(declaration) + + return super.visitFunction(declaration).also { + functions.removeLast() + if (named) owners.removeLast() + } + } + + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + val callee = expression.symbol.owner + callee.parameters.forEach { parameter -> + if (!stages.types.isStageLambda(parameter.type)) return@forEach + val argument = expression.arguments[parameter] ?: return@forEach + val lambda = argument as? IrFunctionExpression + if (lambda == null) { + val forwarded = (argument as? IrGetValue)?.symbol?.owner as? IrValueParameter + if (forwarded == null || !stages.types.isStageLambda(forwarded.type)) { + stages.reporter.error( + argument, + StageMessages.FUNCTION_VALUE + ) + } + return@forEach + } + lift(lambda, callee.name.asString(), parameter)?.let { expression.arguments[parameter] = it } + } + return expression + } + + private fun lift(expression: IrFunctionExpression, callee: String, parameter: IrValueParameter): IrExpression? { + val lambda = expression.function + val constructor = stages.symbols.stageLambdaConstructor(lambda.parameters.size) ?: run { + stages.reporter.error(expression, "A @State lambda takes at most 8 parameters, its receiver included") + return null + } + val captured = captures(lambda) ?: return null + locals.redirect(lambda, scriptThis()) + val owner = owners.lastOrNull() ?: "script" + val key = "$owner$$callee" + val ordinal = counters.merge(key, 1, Int::plus)!! + val name = "$key#$ordinal" + val parameters = createStage(lambda, name, captured) + + val scriptThis = scriptThis() + return stages.builder(functions.last().symbol, expression).irBlock(resultType = parameter.type) { + val tag = irTemporary(irCallConstructor(stages.symbols.compoundTagConstructor, emptyList())) + with(stages) { + parameters.forEach { (value, copy) -> + +callObject( + stages.symbols.put, + irGet(tag), + irString(copy.name.asString()), + serializer(value.type), + irGet(value) + ) + } + val key = callObject(stages.symbols.keyOf, irGet(scriptThis)) + val typeArguments = lambda.parameters.map { it.type } + lambda.returnType + val lambdaValue = irCallConstructor(constructor, typeArguments).apply { + arguments[0] = key + arguments[1] = irString(name) + arguments[2] = irGet(tag) + arguments[3] = serializer(lambda.returnType) + } + +irImplicitCast(lambdaValue, parameter.type) + } + } + } + + /** The values from outside [lambda] that it reads, or null when one of them cannot be saved. */ + private fun captures(lambda: IrSimpleFunction): List? { + val inside = declaredIn(lambda) + val captured = LinkedHashSet() + var valid = true + lambda.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + private fun checkLocal(declaration: IrDeclaration, expression: IrElement, description: String) { + val parents = generateSequence(declaration.parent as? IrDeclaration) { it.parent as? IrDeclaration }.toList() + if (parents.any { it === lambda } || parents.none { it is IrFunction }) return + stages.reporter.error( + expression, + StageMessages.localClosure(description) + ) + valid = false + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + val function = expression.symbol.owner + if (function.isLocal() && !function.isInside(lambda) && function.typeParameters.isNotEmpty()) { + stages.reporter.error(expression, StageMessages.localClosure("generic local function '${function.name}'")) + valid = false + } + } + + override fun visitFunctionReference(expression: IrFunctionReference) { + super.visitFunctionReference(expression) + val function = expression.symbol.owner + checkLocal(function, expression, "local function '${function.name}'") + } + + override fun visitConstructorCall(expression: IrConstructorCall) { + super.visitConstructorCall(expression) + val klass = expression.symbol.owner.parent as IrClass + checkLocal(klass, expression, "local class '${klass.name}'") + } + + override fun visitGetValue(expression: IrGetValue) { + val value = expression.symbol.owner + if (value !in inside && !stages.isScriptThis(value)) captured += value + } + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + if (expression.symbol.owner !in inside) { + stages.reporter.error( + expression, + StageMessages.assignCapture(expression.symbol.owner.name.asString()) + ) + valid = false + } + } + }) + locals.reachableFrom(lambda).forEach { function -> captured += locals.freeValues(function) } + captured.forEach { value -> + if (value is IrVariable && value.isVar) { + stages.reporter.error( + lambda, StageMessages.mutableCapture(value.name.asString()) + ) + valid = false + } + stages.types.problem(value.type)?.let { problem -> + stages.reporter.error( + lambda, StageMessages.unsavedCapture(value.name.asString(), problem) + ) + valid = false + } + } + return captured.toList().takeIf { valid } + } + + private fun createStage( + lambda: IrSimpleFunction, + name: String, + captured: List, + ): Map { + val script = stages.script + val stage = stages.context.irFactory.buildFun { + startOffset = lambda.startOffset + endOffset = lambda.endOffset + this.name = Name.identifier($$"stage$lambda$$${lifted.size}") + returnType = lambda.returnType + isSuspend = true + visibility = DescriptorVisibilities.PUBLIC + origin = IrDeclarationOrigin.DEFINED + } + stage.parent = script + val receiver = stage.buildReceiverParameter { type = script.defaultType } + stage.parameters = listOf(receiver) + val names = HashSet() + fun parameter(value: IrValueDeclaration, fallback: String): IrValueParameter { + val base = value.name.asString().takeIf { !value.name.isSpecial } ?: fallback + return stage.addValueParameter(generateSequence(base) { "${it}_" }.first(names::add), value.type) + } + val parameters = captured.associateWith { parameter(it, "captured") } + val ownParameters = lambda.parameters.mapIndexed { index, value -> + value to parameter(value, if (value.kind == IrParameterKind.ExtensionReceiver) "receiver" else "p$index") + }.toMap() + stage.annotations = listOf(stages.stateAnnotation(name)) + val inside = declaredIn(lambda) + stage.body = lambda.body + stage.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitGetValue(expression: IrGetValue): IrExpression { + val value = expression.symbol.owner + val replacement = parameters[value] ?: ownParameters[value] + ?: receiver.takeIf { stages.isScriptThis(value) && value !in inside } + ?: return expression + return IrGetValueImpl( + expression.startOffset, + expression.endOffset, + replacement.type, + replacement.symbol, + expression.origin + ) + } + + override fun visitReturn(expression: IrReturn): IrExpression { + expression.transformChildrenVoid(this) + if (expression.returnTargetSymbol == lambda.symbol) expression.returnTargetSymbol = stage.symbol + return expression + } + }) + stage.patchDeclarationParents(script) + lifted[stage] = parameters.size + return parameters + } + + /** The script instance as seen from where the lambda is written. */ + private fun scriptThis(): IrValueParameter { + functions.reversed().forEach { function -> + function.parameters.firstOrNull { it.kind == IrParameterKind.DispatchReceiver && stages.isScriptThis(it) } + ?.let { return it } + } + return stages.script.thisReceiver ?: error("Script class ${stages.script.name} has no receiver") + } +} + +/** Every value declared in [root]: its own parameters, its variables and those of anything nested in it. */ +internal fun declaredIn(root: IrElement): Set { + val declared = Collections.newSetFromMap(IdentityHashMap()) + if (root is IrFunction) declared += root.parameters + root.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitDeclaration(declaration: IrDeclarationBase) { + if (declaration is IrValueDeclaration) declared += declaration + if (declaration is IrFunction) declared += declaration.parameters + super.visitDeclaration(declaration) + } + }) + return declared +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt new file mode 100644 index 000000000..90d7d94be --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLambdaResult.kt @@ -0,0 +1,30 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.builders.irBlockBody +import org.jetbrains.kotlin.ir.builders.irReturn +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.name.Name + +/** A saved lambda refers to this generated factory to recover its result serializer after a restart. */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal fun StageScope.lambdaResult(function: IrSimpleFunction, name: String): IrSimpleFunction { + val factory = context.irFactory.buildFun { + startOffset = function.startOffset + endOffset = function.endOffset + this.name = Name.identifier("stage\$result\$${function.name.asString()}") + returnType = symbols.serializerType + visibility = DescriptorVisibilities.PUBLIC + } + factory.parent = script + factory.parameters = listOf(factory.buildReceiverParameter { type = script.defaultType }) + factory.annotations = listOf(resultAnnotation(name)) + factory.body = builder(factory.symbol, function).irBlockBody { + +irReturn(serializer(function.returnType)) + } + return factory +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt new file mode 100644 index 000000000..79eba67b2 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLayout.kt @@ -0,0 +1,281 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.declarations.IrValueDeclaration +import org.jetbrains.kotlin.ir.declarations.IrVariable +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.acceptVoid +import java.util.* + +/** How a stage call stands in its statement once [StageNormalizer] is done. */ +internal enum class SiteForm { STATEMENT, VARIABLE, ASSIGN, RETURN } + +/** + * A stage call, action call or checkpoint inside a stage. Positions in the frame are `index * 2` while the call runs and + * `index * 2 + 1` after it; [name] is what the save stores instead. + */ +internal class StageSite( + val index: Int, + val name: String, + val call: IrCall, + val kind: SiteKind, + val form: SiteForm, + val tail: Boolean, +) { + /** The locals that are still needed after the call, so the frame saves them when it gets here. */ + var live: List = emptyList() + + val callPosition: Int get() = index * 2 + val afterPosition: Int get() = index * 2 + 1 +} + +/** + * Finds the sites of a normalized stage body and names them by where they are: the branches and loops around + * them, then the stage called and how many calls to it precede on that level (`when#1.else/trial#1`). A + * checkpoint is named by its own name. Then works out which locals each site has to save. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageLayout(private val stages: StageScope, private val function: IrSimpleFunction) { + val sites = ArrayList() + private val byCall = IdentityHashMap() + private val checkpoints = HashSet() + + fun site(call: IrCall): StageSite? = byCall[call] + + /** Every local some site saves, in the order they are declared. */ + var saved: List = emptyList() + private set + + /** The key each saved local is stored under in the frame. */ + val keys = IdentityHashMap() + + fun layout(statements: List) { + level(statements, "", HashMap(), tail = true, inTry = false) + liveness(statements) + } + + /** The range of positions of the sites inside [element], or null when there are none. */ + fun positions(element: IrElement): IntRange? { + var first = Int.MAX_VALUE + var last = Int.MIN_VALUE + element.acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) = Unit + + override fun visitCall(expression: IrCall) { + byCall[expression]?.let { + first = minOf(first, it.callPosition) + last = maxOf(last, it.afterPosition) + } + super.visitCall(expression) + } + }) + return if (first > last) null else first..last + } + + private fun level( + statements: List, + path: String, + counters: HashMap, + tail: Boolean, + inTry: Boolean, + ) { + statements.forEachIndexed { index, statement -> + val last = tail && index == statements.lastIndex + statement(statement, path, counters, last, inTry) + } + } + + private fun statement( + statement: IrStatement, + path: String, + counters: HashMap, + tail: Boolean, + inTry: Boolean, + ) { + siteCall(statement)?.let { (call, form) -> + register(call, form, path, counters, tail, inTry) + return + } + when (statement) { + is IrWhen -> { + val kind = if (statement.origin == IrStatementOrigin.IF) "if" else "when" + val label = "$path$kind#${counters.next(kind)}" + statement.branches.forEachIndexed { index, branch -> + val name = when { + branch is IrElseBranch || index == statement.branches.lastIndex && branch.condition.isTrue() -> "else" + kind == "if" -> "then" + else -> "b${index + 1}" + } + level(statementsOf(branch.result), "$label.$name/", HashMap(), tail, inTry) + } + } + + is IrWhileLoop, is IrDoWhileLoop -> { + level( + statementsOf(statement.body), + "${path}loop#${counters.next("loop")}/", + HashMap(), + tail = false, + inTry = inTry + ) + } + + is IrTry -> level( + statementsOf(statement.tryResult), + "${path}try#${counters.next("try")}/", + HashMap(), + tail = false, + inTry = true + ) + + is IrBlock -> level(statement.statements, path, counters, tail, inTry) + } + } + + private fun register( + call: IrCall, + form: SiteForm, + path: String, + counters: HashMap, + tail: Boolean, + inTry: Boolean, + ) { + val kind = stages.siteKind(call)!! + val name = when (kind) { + SiteKind.CHECKPOINT -> { + val name = (call.arguments[0] as? IrConst)?.value as? String + if (name.isNullOrBlank()) { + stages.reporter.error(call, "A checkpoint needs a constant name") + } else if (!checkpoints.add(name)) { + stages.reporter.error(call, "Checkpoint '$name' appears twice in '${function.name}'") + } + name ?: "checkpoint" + } + + SiteKind.STAGE -> { + val callee = stages.stageName(call.symbol.owner) + "$path$callee#${counters.next("call:$callee")}" + } + + SiteKind.ACTION -> { + val callee = call.symbol.owner.name.asString() + "$path$callee#${counters.next("call:$callee")}" + } + + SiteKind.LAMBDA -> { + val receiver = (call.dispatchReceiver as? IrGetValue)?.symbol?.owner?.name?.asString() ?: "lambda" + "$path$receiver#${counters.next("call:$receiver")}" + } + } + val ofScript = call.symbol.owner.parameters.firstOrNull()?.kind == IrParameterKind.DispatchReceiver + val isTail = kind == SiteKind.STAGE && ofScript && !inTry && when (form) { + SiteForm.RETURN -> true + SiteForm.STATEMENT -> tail && function.returnType.isUnit() && call.type.isUnit() + else -> false + } && call.arguments.drop(1).all { it != null } + StageSite(sites.size, name, call, kind, form, isTail).also { + sites += it + byCall[call] = it + } + } + + /** Works out [StageSite.live] in evaluation order: a local is saved at a site when it is read after it. */ + private fun liveness(statements: List) { + var clock = 0 + val declared = IdentityHashMap() + val reads = IdentityHashMap>() + val at = IdentityHashMap() + val loops = ArrayList() + var depth = 0 + + val visitor = object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitFunction(declaration: IrFunction) { + depth++ + super.visitFunction(declaration) + depth-- + } + + override fun visitVariable(declaration: IrVariable) { + declaration.initializer?.acceptVoid(this) + if (depth == 0) declared[declaration] = ++clock + } + + override fun visitGetValue(expression: IrGetValue) { + reads.getOrPut(expression.symbol.owner, ::ArrayList) += ++clock + } + + override fun visitSetValue(expression: IrSetValue) { + expression.value.acceptVoid(this) + ++clock + } + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + byCall[expression]?.let { at[it] = ++clock } + } + + override fun visitLoop(loop: IrLoop) { + val start = ++clock + super.visitLoop(loop) + loops += start..++clock + } + } + statements.forEach { it.acceptVoid(visitor) } + + val saved = LinkedHashSet() + sites.forEach { site -> + val time = at.getValue(site) + site.live = declared.entries.filter { (variable, declaredAt) -> + if (declaredAt >= time) return@filter false + val uses = reads[variable].orEmpty() + uses.any { it > time } || loops.any { loop -> time in loop && declaredAt < loop.first && uses.any { it in loop } } + }.sortedBy { it.value }.map { it.key } + saved += site.live + } + this.saved = saved.sortedBy { declared.getValue(it) } + val names = HashMap() + this.saved.forEach { variable -> + val base = variable.name.asString() + val count = names.merge(base, 1, Int::plus)!! + keys[variable] = if (count == 1) base else "$base#$count" + } + } + + private fun siteCall(statement: IrStatement): Pair? { + fun IrExpression?.site(): IrCall? = (this as? IrCall)?.takeIf { stages.siteKind(it) != null } + return when (statement) { + is IrCall -> statement.site()?.let { it to SiteForm.STATEMENT } + is IrTypeOperatorCall if statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT -> statement.argument.site() + ?.let { it to SiteForm.STATEMENT } + + is IrVariable -> statement.initializer.site()?.let { it to SiteForm.VARIABLE } + is IrSetValue -> statement.value.site()?.let { it to SiteForm.ASSIGN } + is IrReturn -> statement.value.site()?.takeIf { statement.returnTargetSymbol == function.symbol } + ?.let { it to SiteForm.RETURN } + + else -> null + } + } + + private fun HashMap.next(key: String): Int = merge(key, 1, Int::plus)!! + + private fun IrExpression.isTrue() = this is IrConst && value == true +} + +internal fun statementsOf(expression: IrExpression?): List = when (expression) { + null -> emptyList() + is IrContainerExpression -> expression.statements + else -> listOf(expression) +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt new file mode 100644 index 000000000..4a0de0db6 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageLocalFunctions.kt @@ -0,0 +1,188 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.IrGetValue +import org.jetbrains.kotlin.ir.expressions.IrSetValue +import org.jetbrains.kotlin.ir.expressions.impl.IrGetValueImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.patchDeclarationParents +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name + +/** + * Lets a `@State` lambda call the local functions of the code it is written in. A lifted lambda leaves that code, + * so every such function gets a copy at the top level of the script, with what it used from around it as explicit + * parameters; the lambda captures those values, which makes them saved like any capture. The original stays for + * its other callers. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageLocalFunctions(private val stages: StageScope) { + private class Copy(val function: IrSimpleFunction, val free: List) + + private val copies = HashMap() + + /** The copies made, which belong to the script. */ + val lifted = ArrayList() + + /** The local functions declared outside [root] that it calls, directly or through one another. */ + fun reachableFrom(root: IrFunction): Set { + val found = LinkedHashSet() + val queue = ArrayDeque(calledOutside(root)) + while (queue.isNotEmpty()) { + val function = queue.removeFirst() + if (found.add(function)) queue += calledOutside(function) + } + return found + } + + /** What [function] and the local functions it reaches read or write from around them, in a stable order. */ + fun freeValues(function: IrSimpleFunction): List = + (setOf(function) + reachableFrom(function)).flatMap(::directFree).distinct() + + /** + * Points the calls in [root] to local functions declared outside it at their copies. [dispatch] is the script + * instance there, and [valueOf] gives what to pass for each free value of a callee. + */ + fun redirect( + root: IrFunction, + dispatch: IrValueDeclaration, + valueOf: (IrValueDeclaration) -> IrValueDeclaration = { it }, + ) { + root.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + val callee = expression.symbol.owner + if (!callee.isLocal() || callee.isInside(root)) return expression + val copy = copyOf(callee) + return stages.builder(root.symbol, expression).run { + callCopy(copy, callee, expression, irGet(dispatch), copy.free.map { irGet(valueOf(it)) }) + } + } + }) + } + + private fun copyOf(function: IrSimpleFunction): Copy { + copies[function]?.let { return it } + val free = freeValues(function) + val script = stages.script + val copy = function.deepCopyWithSymbols(script) + copy.attributeOwnerId = copy + copy.name = Name.identifier($$"stage$local$$${function.name.asString()}$$${lifted.size}") + copy.visibility = DescriptorVisibilities.PRIVATE + copy.origin = IrDeclarationOrigin.DEFINED + copy.parent = script + val dispatch = copy.buildReceiverParameter { type = script.defaultType } + val own = copy.parameters.toList() + val names = own.map { it.name.asString() }.toMutableSet() + val freeParameters = free.map { value -> + val base = value.name.asString().takeIf { !value.name.isSpecial } ?: "captured" + copy.addValueParameter(generateSequence(base) { "${it}_" }.first(names::add), value.type) + } + copy.parameters = + listOf(dispatch) + own.filter { it.kind == IrParameterKind.ExtensionReceiver } + freeParameters + own.filter { it.kind == IrParameterKind.Regular } + val result = Copy(copy, free) + copies[function] = result + lifted += copy + + val parameterOf = free.zip(freeParameters).toMap() + copy.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitGetValue(expression: IrGetValue): IrExpression { + val value = expression.symbol.owner + val replacement: IrValueParameter = + parameterOf[value] ?: dispatch.takeIf { stages.isScriptThis(value) && value !in declaredIn(copy) } + ?: return expression + return IrGetValueImpl( + expression.startOffset, + expression.endOffset, + replacement.type, + replacement.symbol, + expression.origin + ) + } + + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + if (expression.symbol != copy.symbol) return expression + return stages.builder(copy.symbol, expression).run { + callCopy(result, function, expression, irGet(dispatch), freeParameters.map { irGet(it) }) + } + } + }) + redirect(copy, dispatch) { value -> parameterOf[value] ?: value } + copy.patchDeclarationParents(script) + return result + } + + /** A call of [copy] in place of [call] of [original]: the script, its receiver, the free values, its arguments. */ + private fun IrBuilderWithScope.callCopy( + copy: Copy, + original: IrSimpleFunction, + call: IrCall, + dispatch: IrExpression, + free: List, + ): IrCall = irCall(copy.function.symbol, call.type).apply { + var next = 0 + arguments[next++] = dispatch + original.parameters.forEachIndexed { index, parameter -> + if (parameter.kind == IrParameterKind.ExtensionReceiver) arguments[next++] = call.arguments[index] + } + free.forEach { arguments[next++] = it } + original.parameters.forEachIndexed { index, parameter -> + if (parameter.kind == IrParameterKind.Regular) arguments[next++] = call.arguments[index] + } + } + + private fun calledOutside(root: IrFunction): Set { + val found = LinkedHashSet() + root.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + val callee = expression.symbol.owner + if (callee.isLocal() && !callee.isInside(root) && callee !== root) found += callee + } + }) + return found + } + + private fun directFree(function: IrSimpleFunction): List { + val inside = declaredIn(function) + val free = LinkedHashSet() + function.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitGetValue(expression: IrGetValue) { + val value = expression.symbol.owner + if (value !in inside && !stages.isScriptThis(value)) free += value + } + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + if (expression.symbol.owner !in inside) free += expression.symbol.owner + } + }) + return free.toList() + } +} + +/** Whether this is a function declared inside another one. */ +internal fun IrFunction.isLocal(): Boolean = parent is IrFunction || visibility == DescriptorVisibilities.LOCAL + +/** Whether this is declared inside [owner], at any depth. */ +internal fun IrDeclaration.isInside(owner: IrDeclarationParent): Boolean = + generateSequence(parent) { (it as? IrDeclaration)?.parent }.any { it === owner } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt new file mode 100644 index 000000000..70ed40fd4 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageNormalizer.kt @@ -0,0 +1,465 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.declarations.IrVariable +import org.jetbrains.kotlin.ir.declarations.impl.IrVariableImpl +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.* +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.symbols.impl.IrVariableSymbolImpl +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.isNothing +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.* +import org.jetbrains.kotlin.ir.visitors.* +import org.jetbrains.kotlin.name.Name + +/** + * Brings a stage body into the shape [StageRewriter] works on: every stage call and checkpoint becomes a statement + * of its own: `call()`, `val x = call()`, `x = call()` or `return call()` - whose arguments hold no stage call, + * and conditions of `if`, `when` and `while` hold none either. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageNormalizer(private val stages: StageScope, private val function: IrSimpleFunction) { + private val builtIns = stages.context.irBuiltIns + private val intPlus: IrSimpleFunctionSymbol = + builtIns.intClass.owner.declarations.filterIsInstance() + .single { it.name.asString() == "plus" && it.parameters.size == 2 && it.parameters[1].type == builtIns.intType }.symbol + private var temporaries = 0 + + /** The copies of the elements `for` loops go over, which a story saves while such a loop runs. */ + val loopCopies = HashSet() + + /** Statements that must run first, then a value that holds no stage call except possibly at its top. */ + private class Lifted(val before: List, val value: IrExpression) + + fun normalize(statements: List): List = statements.flatMap(::statement) + + /** Whether [element] calls a stage or a checkpoint, not counting nested functions. */ + fun hasSite(element: IrElement): Boolean { + var found = false + element.acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + if (!found) element.acceptChildrenVoid(this) + } + + override fun visitFunction(declaration: IrFunction) = Unit + + override fun visitCall(expression: IrCall) { + if (stages.siteKind(expression) != null) found = true else super.visitCall(expression) + } + }) + return found + } + + private fun isSiteCall(expression: IrExpression) = expression is IrCall && stages.siteKind(expression) != null + + private fun statement(statement: IrStatement): List { + if (!hasSite(statement)) return listOf(statement) + return when (statement) { + is IrVariable -> value(statement.initializer!!).let { lifted -> + statement.initializer = lifted.value + lifted.before + statement + } + + is IrSetValue -> value(statement.value).let { lifted -> + statement.value = lifted.value + lifted.before + statement + } + + is IrReturn -> value(statement.value).let { lifted -> + statement.value = lifted.value + lifted.before + statement + } + + is IrThrow -> value(statement.value).let { lifted -> + val operand = materialize(lifted.value) + statement.value = operand.value + lifted.before + operand.before + statement + } + + is IrTypeOperatorCall if statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT -> value(statement.argument).let { lifted -> + statement.argument = lifted.value + lifted.before + statement + } + + is IrWhen -> listOf(whenStatement(statement)) + is IrWhileLoop -> whileLoop(statement) + is IrDoWhileLoop -> doWhileLoop(statement) + is IrBlock if statement.origin == IrStatementOrigin.FOR_LOOP -> forLoop(statement) + is IrComposite -> normalize(statement.statements) + is IrBlock -> { + val normalized = normalize(statement.statements) + statement.statements.clear() + statement.statements += normalized + listOf(statement) + } + + is IrTry -> { + statement.tryResult = block(normalize(statementsOf(statement.tryResult))) + statement.type = builtIns.unitType + listOf(statement) + } + + is IrExpression -> value(statement).let { it.before + it.value } + else -> unsupported(statement) + } + } + + /** Lifts the stage calls out of [expression]. */ + private fun value(expression: IrExpression): Lifted { + if (!hasSite(expression)) return Lifted(emptyList(), expression) + return when (expression) { + is IrFunctionAccessExpression -> { + val lifted = operands(expression.arguments.toList()) + lifted.second.forEachIndexed { index, operand -> expression.arguments[index] = operand } + Lifted(lifted.first, expression) + } + + is IrWhen -> whenValue(expression) + is IrContainerExpression -> { + val statements = expression.statements + val last = statements.lastOrNull() as? IrExpression + if (last == null || expression.type.isUnit()) return Lifted(normalize(statements), unit()) + val lifted = value(last) + Lifted(normalize(statements.dropLast(1)) + lifted.before, lifted.value) + } + + is IrTypeOperatorCall -> { + val lifted = value(expression.argument) + val operand = materialize(lifted.value) + expression.argument = operand.value + Lifted(lifted.before + operand.before, expression) + } + + is IrGetField -> { + val lifted = operands(listOf(expression.receiver)) + expression.receiver = lifted.second[0] + Lifted(lifted.first, expression) + } + + is IrSetField -> { + val lifted = operands(listOf(expression.receiver, expression.value)) + expression.receiver = lifted.second[0] + expression.value = lifted.second[1]!! + Lifted(lifted.first, expression) + } + + is IrStringConcatenation -> { + val lifted = operands(expression.arguments.toList()) + lifted.second.forEachIndexed { index, operand -> expression.arguments[index] = operand!! } + Lifted(lifted.first, expression) + } + + is IrReturn, is IrThrow -> Lifted(statement(expression), zero(expression.type)) + is IrTry -> tryValue(expression) + else -> Lifted(unsupported(expression), expression) + } + } + + /** + * Lifts [operands] in the order they are evaluated. Whatever is evaluated before the last operand that calls a + * stage moves into a temporary, so resuming that stage does not evaluate it again. + */ + private fun operands(operands: List): Pair, List> { + val last = operands.indexOfLast { it != null && hasSite(it) } + val before = ArrayList() + val result = operands.toMutableList() + for (index in 0..last) { + val lifted = value(operands[index] ?: continue) + before += lifted.before + result[index] = if (isSiteCall(lifted.value) || index < last && !isTrivial(lifted.value)) { + val temporary = variable(lifted.value.type, initializer = lifted.value) + before += temporary + get(temporary) + } else lifted.value + } + return before to result + } + + private fun whenValue(expression: IrWhen): Lifted { + if (expression.type.isUnit() || expression.type.isNothing()) return Lifted( + listOf(whenStatement(expression)), unit() + ) + val result = variable(expression.type, isVar = true, initializer = zero(expression.type)) + expression.branches.forEach { branch -> branch.result = assign(result, branch.result) } + return Lifted(listOf(result, whenStatement(expression)), get(result)) + } + + private fun tryValue(expression: IrTry): Lifted { + if (expression.type.isUnit()) return Lifted(statement(expression), unit()) + val result = variable(expression.type, isVar = true, initializer = zero(expression.type)) + expression.tryResult = assign(result, expression.tryResult) + expression.catches.forEach { it.result = assign(result, it.result) } + return Lifted(listOf(result) + statement(expression), get(result)) + } + + /** [expression] as statements that end by storing its value in [target]. */ + private fun assign(target: IrVariable, expression: IrExpression): IrExpression { + if (expression is IrContainerExpression) { + val statements = expression.statements + val last = statements.lastOrNull() as? IrExpression ?: return expression + return block(statements.dropLast(1) + assign(target, last)) + } + if (expression.type.isNothing()) return expression + return IrSetValueImpl( + expression.startOffset, expression.endOffset, builtIns.unitType, target.symbol, expression, null + ) + } + + private fun whenStatement(expression: IrWhen): IrExpression { + // `&&` and `||` lose the shape their origins promise to the backend once their branches become statements. + if (expression.origin == IrStatementOrigin.ANDAND || expression.origin == IrStatementOrigin.OROR) expression.origin = + null + val branches = ArrayList() + for ((index, branch) in expression.branches.withIndex()) { + if (branch !is IrElseBranch && hasSite(branch.condition)) { + val lifted = value(branch.condition) + val condition = materialize(lifted.value) + val rest = IrWhenImpl( + expression.startOffset, expression.endOffset, builtIns.unitType, expression.origin + ).apply { + this.branches += IrBranchImpl(branch.startOffset, branch.endOffset, condition.value, branch.result) + this.branches += expression.branches.drop(index + 1) + } + val nested = block(lifted.before + condition.before + whenStatement(rest)) + branches += IrElseBranchImpl(branch.startOffset, branch.endOffset, constTrue(), nested) + break + } + branch.result = block(normalize(statementsOf(branch.result))) + branches += branch + } + expression.branches.clear() + expression.branches += branches + expression.type = builtIns.unitType + return expression + } + + private fun whileLoop(loop: IrWhileLoop): List { + val body = normalize(statementsOf(loop.body)) + if (!hasSite(loop.condition)) { + loop.body = block(body) + return listOf(loop) + } + val lifted = value(loop.condition) + val condition = materialize(lifted.value) + val builder = stages.builder(function.symbol, loop) + val exit = IrWhenImpl(loop.startOffset, loop.endOffset, builtIns.unitType, null).apply { + branches += IrBranchImpl( + loop.startOffset, loop.endOffset, + builder.irCall(builtIns.booleanNotSymbol).apply { arguments[0] = condition.value }, + IrBreakImpl(loop.startOffset, loop.endOffset, builtIns.nothingType, loop), + ) + } + loop.condition = constTrue() + loop.body = block(lifted.before + condition.before + exit + body) + return listOf(loop) + } + + /** + * A condition that calls stages is computed at the end of the body into a value the loop tests. `continue` + * has to compute it as well, so the body runs once inside an inner loop that `continue` leaves instead. + * The body stays a composite: the condition may read what the body declared. + */ + private fun doWhileLoop(loop: IrDoWhileLoop): List { + if (!hasSite(loop.condition)) { + loop.body = loop.body?.let { composite(normalize(statementsOf(it))) } + return listOf(loop) + } + val inner = IrDoWhileLoopImpl(loop.startOffset, loop.endOffset, builtIns.unitType, null) + inner.body = loop.body + inner.condition = stages.builder(function.symbol, loop).irFalse() + inner.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitContinue(jump: IrContinue): IrExpression = if (jump.loop == loop) IrBreakImpl( + jump.startOffset, + jump.endOffset, + builtIns.nothingType, + inner + ) else jump + }) + inner.body = inner.body?.let { composite(normalize(statementsOf(it))) } + val lifted = value(loop.condition) + val condition = variable(builtIns.booleanType, initializer = lifted.value, name = "condition") + loop.body = composite(listOf(inner) + lifted.before + condition) + loop.condition = get(condition) + return listOf(loop) + } + + /** + * Rewrites a `for` that calls stages to count with an index the frame can save. A range of Int is counted as it + * is; anything else is copied into a list first (see `StoryLoops.snapshot`), whose elements must then be + * serializable. A sequence may never end and is an error. + */ + private fun forLoop(block: IrBlock): List { + val iterator = block.statements.firstOrNull() as? IrVariable + val loop = block.statements.getOrNull(1) as? IrWhileLoop + val iterable = (iterator?.initializer as? IrCall)?.arguments?.firstOrNull() + if (iterator?.origin != IrDeclarationOrigin.FOR_LOOP_ITERATOR || loop == null || iterable == null) { + return unsupported(block) + } + + val builder = stages.builder(function.symbol, block) + val index = variable(builtIns.intType, isVar = true, initializer = builder.irInt(-1), name = "index") + val prologue = ArrayList() + val bound: IrExpression + val element: (IrType) -> IrExpression + val progressionClass = stages.symbols.intProgression + val sequenceClass = stages.symbols.sequence + val elementType = nextCall(loop, iterator)?.type + when { + progressionClass == null || !iterable.type.isSubtypeOfClass(progressionClass) -> { + if (sequenceClass != null && iterable.type.isSubtypeOfClass(sequenceClass) || elementType == null) { + stages.reporter.error( + block, + "A for loop that calls stages cannot go over ${iterable.type.render()}: it may never end; call toList() first" + ) + return listOf(block) + } + val listClass = builtIns.listClass.owner + val snapshot = with(stages) { builder.callObject(stages.symbols.loopSnapshot, iterable) } + val listType = builtIns.listClass.typeWith(elementType) + val list = variable(listType, initializer = builder.irImplicitCast(snapshot, listType), name = "list") + loopCopies += list + prologue += list + bound = builder.irCall(listClass.getPropertyGetter("size")!!).apply { arguments[0] = get(list) } + element = { type -> + builder.irImplicitCast(builder.irCall(listClass.getSimpleFunction("get")!!).apply { + arguments[0] = get(list) + arguments[1] = get(index) + }, type) + } + } + + else -> { + val progression = variable(iterable.type, initializer = iterable, name = "range") + fun property(name: String) = builder.irCall(progressionClass.owner.getPropertyGetter(name)!!).apply { + arguments[0] = get(progression) + } + + val first = variable(builtIns.intType, initializer = property("first"), name = "first") + val step = variable(builtIns.intType, initializer = property("step"), name = "step") + val count = variable( + builtIns.intType, + initializer = with(stages) { builder.callObject(stages.symbols.loopCount, get(progression)) }, + name = "count" + ) + prologue += listOf(progression, first, step, count) + bound = get(count) + element = { + with(stages) { + builder.callObject( + stages.symbols.loopElement, get(first), get(step), get(index) + ) + } + } + } + } + + loop.condition = builder.irBlock(resultType = builtIns.booleanType) { + +irSet(index, irCall(intPlus).apply { + arguments[0] = irGet(index) + arguments[1] = irInt(1) + }) + +irCall(builtIns.lessFunByOperandType.getValue(builtIns.intClass)).apply { + arguments[0] = irGet(index) + arguments[1] = bound + } + } + loop.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitCall(expression: IrCall): IrExpression { + expression.transformChildrenVoid(this) + val receiver = expression.arguments.firstOrNull() as? IrGetValue + return if (expression.origin == IrStatementOrigin.FOR_LOOP_NEXT && receiver?.symbol == iterator.symbol) { + element(expression.type) + } else expression + } + }) + loop.origin = null + loop.body = block(normalize(statementsOf(loop.body))) + block.statements.clear() + block.statements += prologue + index + loop + block.origin = null + return listOf(block) + } + + /** The `next()` call of a `for` loop's iterator, which gives the type of its elements. */ + private fun nextCall(loop: IrWhileLoop, iterator: IrVariable): IrCall? { + var found: IrCall? = null + loop.body?.acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + if (found == null) element.acceptChildrenVoid(this) + } + + override fun visitCall(expression: IrCall) { + val receiver = expression.arguments.firstOrNull() as? IrGetValue + if (expression.origin == IrStatementOrigin.FOR_LOOP_NEXT && receiver?.symbol == iterator.symbol) found = + expression + else super.visitCall(expression) + } + }) + return found + } + + /** Moves a stage call out of an operand into a temporary; anything else stays where it is. */ + private fun materialize(expression: IrExpression): Lifted { + if (!isSiteCall(expression)) return Lifted(emptyList(), expression) + val temporary = variable(expression.type, initializer = expression) + return Lifted(listOf(temporary), get(temporary)) + } + + private fun variable( + type: IrType, + isVar: Boolean = false, + initializer: IrExpression? = null, + name: String = "tmp", + ): IrVariable = IrVariableImpl( + initializer?.startOffset ?: function.startOffset, initializer?.endOffset ?: function.endOffset, + IrDeclarationOrigin.IR_TEMPORARY_VARIABLE, IrVariableSymbolImpl(), + Name.identifier("$name\$${temporaries++}"), type, isVar, isConst = false, isLateinit = false, + ).also { + it.parent = function + it.initializer = initializer + } + + private fun get(variable: IrVariable): IrExpression = stages.builder(function.symbol, variable).irGet(variable) + + private fun isTrivial(expression: IrExpression): Boolean = when (expression) { + is IrConst, is IrGetObjectValue, is IrGetEnumValue, is IrClassReference, is IrFunctionExpression -> true + is IrGetValue -> (expression.symbol.owner as? IrVariable)?.isVar != true + else -> false + } + + private fun statementsOf(expression: IrExpression?): List = when (expression) { + null -> emptyList() + is IrComposite -> expression.statements + is IrBlock if expression.origin == null -> expression.statements + else -> listOf(expression) + } + + private fun block(statements: List): IrBlock = + IrBlockImpl(function.startOffset, function.endOffset, builtIns.unitType, null, statements) + + private fun composite(statements: List): IrComposite = IrCompositeImpl( + function.startOffset, + function.endOffset, + builtIns.unitType + ).apply { this.statements += statements } + + private fun zero(type: IrType): IrExpression = stages.blank(type, function.startOffset, function.endOffset) + + private fun unit(): IrExpression = stages.builder(function.symbol, function).irGetObject(builtIns.unitClass) + + private fun constTrue(): IrExpression = stages.builder(function.symbol, function).irTrue() + + private fun unsupported(element: IrElement): List { + stages.reporter.error(element, "A stage cannot be called here; move the call into a statement of its own") + return emptyList() + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt new file mode 100644 index 000000000..a92b866e9 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageReporter.kt @@ -0,0 +1,17 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.IrFile +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageDiagnostics + +/** Reports stage errors against the file being compiled and remembers whether there were any. */ +internal class StageReporter(private val context: IrPluginContext, private val file: IrFile) { + var errors = 0 + private set + + fun error(element: IrElement, message: String) { + errors++ + context.diagnosticReporter.at(element, file).report(StageDiagnostics.STAGE_ERROR, message) + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt new file mode 100644 index 000000000..541c1d984 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageRewriter.kt @@ -0,0 +1,563 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.declarations.buildReceiverParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildVariable +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.* +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.isNothing +import org.jetbrains.kotlin.ir.types.isUnit +import org.jetbrains.kotlin.ir.types.makeNullable +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.defaultValueForType +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name + +/** + * Rewrites a normalized stage body so its frame can lead it back to where it was saved. + * + * The body keeps its structure. Every statement runs only while the stage is not resuming, or when the position + * it resumes to lies inside it; a branch or loop that holds that position is entered without evaluating its + * condition again. A stage call saves the live locals, marks the frame and cStatealls; resuming it either calls the + * saved callee again or takes the result it had. A tail call leaves the frame to the callee. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageRewriter( + private val stages: StageScope, + private val function: IrSimpleFunction, + private val layout: StageLayout, +) { + private val symbols = stages.symbols + private val builtIns = stages.context.irBuiltIns + private val returnType = function.returnType + private val saved = layout.saved.toSet() + private lateinit var frame: IrVariable + + /** Replaces the body of the stage with its rewritten form. */ + fun rewrite(statements: List, name: String, hash: Int) { + val builder = stages.builder(function.symbol, function) + val receiver = function.parameters.first { it.kind == IrParameterKind.DispatchReceiver } + frame = buildVariable( + function, + function.startOffset, + function.endOffset, + IrDeclarationOrigin.DEFINED, + Name.identifier("\$frame"), + symbols.frameType + ) + frame.initializer = with(stages) { + builder.callObject( + symbols.enter, builder.irGet(receiver), builder.irString(name), builder.irInt(hash), + builder.irCall(builtIns.arrayOf).apply { + typeArguments[0] = builtIns.stringType + arguments[0] = builder.irVararg(builtIns.stringType, layout.sites.map { builder.irString(it.name) }) + }, + ) + } + + if (!returnType.isUnit()) reportReturns(statements) + val rewritten = statements.map(::statement) + val body = ArrayList() + stages.arguments(function).forEach { parameter -> + body += frameCall( + builder, + "saveArg", + builder.irString(stages.argumentName(function, parameter)), + serializer(builder, parameter.type), + builder.irGet(parameter) + ) + } + layout.saved.forEach { variable -> + variable.isVar = true + variable.initializer = zero(variable.type) + body += variable + } + if (layout.saved.isNotEmpty()) { + body += builder.irIfThen(builtIns.unitType, frameCall(builder, "isResuming"), builder.irBlock { + layout.saved.forEach { variable -> + val restored = frameCall( + this, + "restore", + irString(layout.keys.getValue(variable)), + serializer(this, variable.type), + irGet(variable) + ) + // A local the resumed site did not save comes back as the null it was declared with. + +irSet(variable, irAs(restored, variable.type.makeNullable())) + } + }) + } + body += rewritten + if (!returnType.isUnit()) body += frameCall(builder, "lost") + + val failure = buildVariable( + function, + function.startOffset, + function.endOffset, + IrDeclarationOrigin.CATCH_PARAMETER, + Name.identifier("\$failure"), + builtIns.throwableType + ) + val rethrow = builder.irBlock(resultType = builtIns.nothingType) { + +frameCall(this, "abort", irGet(failure)) + +IrThrowImpl(startOffset, endOffset, builtIns.nothingType, irGet(failure)) + } + val guarded = IrTryImpl( + function.startOffset, function.endOffset, + if (returnType.isUnit()) builtIns.unitType else builtIns.nothingType, + IrBlockImpl(function.startOffset, function.endOffset, builtIns.unitType, null, body), + listOf(IrCatchImpl(function.startOffset, function.endOffset, failure, rethrow)), + frameCall(builder, "leave"), + ) + function.body = builder.irBlockBody { + +frame + +guarded + } + } + + private fun statement(statement: IrStatement): IrStatement { + siteOf(statement)?.let { return expandSite(it, statement) } + if (statement is IrVariable && statement in saved) return assignSaved(statement) + val positions = layout.positions(statement) ?: return guardFresh(statement) + return when (statement) { + is IrWhen -> guard(positions, routeWhen(statement)) + is IrWhileLoop -> guard(positions, routeWhile(statement)) + is IrDoWhileLoop -> guard(positions, statement.also { it.body = rewriteBody(it.body) }) + is IrTry -> guard(positions, statement.also { it.tryResult = rewriteBlock(it.tryResult)!! }) + is IrBlock -> guard(positions, statement.also { block -> + val rewritten = block.statements.map(::statement) + block.statements.clear() + block.statements += rewritten + }) + + else -> statement.also { + stages.reporter.error( + it, "A stage cannot be called here; move the call into a statement of its own" + ) + } + } + } + + /** A saved local is declared at the top of the stage, so where it was declared it is only assigned. */ + private fun assignSaved(variable: IrVariable): IrStatement { + val builder = stages.builder(function.symbol, variable) + val initializer = variable.initializer ?: return builder.irGetObject(builtIns.unitClass) + return builder.irIfThen( + builtIns.unitType, not(builder, frameCall(builder, "isResuming")), builder.irSet(variable, initializer) + ) + } + + /** A statement without sites runs only when the stage is not resuming. Declarations stay where they are. */ + private fun guardFresh(statement: IrStatement): IrStatement { + val builder = stages.builder(function.symbol, statement) + if (statement is IrVariable) { + val initializer = statement.initializer ?: return statement + statement.initializer = builder.irIfThenElse( + statement.type, frameCall(builder, "isResuming"), zero(statement.type), initializer + ) + return statement + } + val expression = statement as? IrExpression ?: return statement + return builder.irIfThen(builtIns.unitType, not(builder, frameCall(builder, "isResuming")), expression) + } + + private fun guard(positions: IntRange, statement: IrExpression): IrExpression { + val builder = stages.builder(function.symbol, statement) + return builder.irIfThen( + builtIns.unitType, + frameCall(builder, "enters", builder.irInt(positions.first), builder.irInt(positions.last)), + statement + ) + } + + /** Each branch is taken when the stage resumes into it, or when it is not resuming and its condition holds. */ + private fun routeWhen(expression: IrWhen): IrWhen { + val builder = stages.builder(function.symbol, expression) + val branches = expression.branches.map { branch -> + val positions = layout.positions(branch.result) + val fresh = and(builder, not(builder, frameCall(builder, "isResuming")), branch.condition) + val condition = positions?.let { + or(builder, frameCall(builder, "resumesInto", builder.irInt(it.first), builder.irInt(it.last)), fresh) + } ?: fresh + IrBranchImpl(branch.startOffset, branch.endOffset, condition, rewriteBlock(branch.result)!!) as IrBranch + } + expression.branches.clear() + expression.branches += branches + return expression + } + + /** A loop the stage resumes into is entered without its condition, which held when the stage was saved. */ + private fun routeWhile(loop: IrWhileLoop): IrExpression = stages.builder(function.symbol, loop).irBlock { + val entering = irTemporary(frameCall(this, "isResuming"), "resuming", isMutable = true) + loop.condition = or(this, irGet(entering), loop.condition) + loop.body = IrBlockImpl( + loop.startOffset, + loop.endOffset, + builtIns.unitType, + null, + listOf(irSet(entering, irFalse()), rewriteBlock(loop.body)!!) + ) + +loop + } + + private fun rewriteBlock(expression: IrExpression?): IrExpression? = expression?.let { + IrBlockImpl(it.startOffset, it.endOffset, builtIns.unitType, null, statementsOf(it).map(::statement)) + } + + /** The body of a do-while stays a composite: its condition may read what the body declares. */ + private fun rewriteBody(expression: IrExpression?): IrExpression? = expression?.let { + if (it !is IrComposite) return rewriteBlock(it) + IrCompositeImpl( + it.startOffset, + it.endOffset, + builtIns.unitType + ).apply { statements += statementsOf(it).map(::statement) } + } + + private fun expandSite(site: StageSite, statement: IrStatement): IrStatement { + val builder = stages.builder(function.symbol, statement) + return when (site.form) { + SiteForm.STATEMENT -> if (site.tail) tailCall(builder, site) else value(builder, site, used = false) + SiteForm.VARIABLE -> { + val variable = statement as IrVariable + val value = value(builder, site, used = true) + if (variable in saved) assignAt(builder, site, builder.irSet(variable, value)) + else variable.also { it.initializer = value } + } + + SiteForm.ASSIGN -> assignAt( + builder, + site, + (statement as IrSetValue).also { it.value = value(builder, site, used = true) }) + + SiteForm.RETURN -> if (site.tail) tailCall(builder, site) else { + (statement as IrReturn).also { + it.value = returning(builder, value(builder, site, used = true), site.call.type) + } + } + } + } + + /** + * Assigns the value of a site only when the stage runs it or resumes to it: resuming elsewhere skips the site, + * and the variable keeps the value it was restored with. + */ + private fun assignAt(builder: IrBuilderWithScope, site: StageSite, assignment: IrExpression): IrExpression = + builder.irIfThen( + builtIns.unitType, + frameCall(builder, "enters", builder.irInt(site.callPosition), builder.irInt(site.afterPosition)), + assignment, + ) + + /** The value of a site: resumed, taken from the save, or computed by calling. */ + private fun value(builder: IrBuilderWithScope, site: StageSite, used: Boolean): IrExpression { + val type = if (used) site.call.type else builtIns.unitType + val keepsResult = !type.isUnit() && !type.isNothing() + if (site.kind == SiteKind.ACTION) return action(builder, site, type, keepsResult) + val fresh = builder.irBlock(resultType = type) { + if (site.kind == SiteKind.CHECKPOINT) { + saveLocals(site) + val restart = site.call.arguments[1] ?: irFalse() + val labels = site.call.arguments.getOrNull(2) + +(if (labels == null) frameCall(this, "checkpoint", irInt(site.index), restart) else frameCall( + this, + "checkpoint", + irInt(site.index), + restart, + labels + )) + } else { + // The frame remembers where it was before the locals of the call overwrite the saved ones. + +frameCall(this, "beginCall", irInt(site.index)) + saveLocals(site) + val completed = frameCall(this, "completeCall", irInt(site.index), site.call) + +(if (keepsResult) irAs(completed, type) else completed) + } + } + val after = frameCall( + builder, + "resumeAfter", + builder.irInt(site.index), + if (keepsResult) serializer(builder, type) else nullSerializer() + ) + val branches = ArrayList() + if (site.kind != SiteKind.CHECKPOINT) { + val resumed = frameCall(builder, "resumeCall", builder.irInt(site.index)) + branches += builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.callPosition)), + if (keepsResult) builder.irAs(resumed, type) else resumed + ) + } + branches += builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.afterPosition)), + if (keepsResult) builder.irAs(after, type) else after + ) + branches += builder.irBranch(frameCall(builder, "isResuming"), zero(type)) + branches += builder.irElseBranch(fresh) + return builder.irWhen(type, branches) + } + + /** + * The value of an action call: performed, finished from its record when a restart interrupted it, or taken from + * the save when it was done before the restart. + */ + private fun action(builder: IrBuilderWithScope, site: StageSite, type: IrType, keepsResult: Boolean): IrExpression { + val resultSerializer = { at: IrBuilderWithScope -> if (keepsResult) serializer(at, type) else nullSerializer() } + fun completed(at: IrBuilderWithScope, value: IrExpression): IrExpression = + frameCall(at, "completeAction", at.irInt(site.index), value, resultSerializer(at)).let { if (keepsResult) at.irAs(it, type) else it } + + val fresh = builder.irBlock(resultType = type) { + +frameCall(this, "beginAction", irInt(site.index)) + saveLocals(site) + +completed(this, site.call) + } + val after = frameCall(builder, "resumeAfter", builder.irInt(site.index), resultSerializer(builder)) + return builder.irWhen( + type, listOf( + builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.callPosition)), + completed(builder, frameCall(builder, "resumeAction", builder.irInt(site.index))) + ), + builder.irBranch( + frameCall(builder, "isResumingAt", builder.irInt(site.afterPosition)), + if (keepsResult) builder.irAs(after, type) else after + ), + builder.irBranch(frameCall(builder, "isResuming"), zero(type)), + builder.irElseBranch(fresh), + ) + ) + } + + /** + * A call in tail position. When the caller runs tail calls, the frame gives its place to the callee and the + * stage returns at once; otherwise it is an ordinary call whose result the stage returns. + */ + private fun tailCall(builder: IrBuilderWithScope, site: StageSite): IrExpression { + val call = site.call + val type = call.type + val callee = call.symbol.owner + val resumed = builder.irBlock(resultType = builtIns.nothingType) { + val callBranch = irBranch( + frameCall(this, "isResumingAt", irInt(site.callPosition)), + frameCall(this, "resumeCall", irInt(site.index)) + ) + if (type.isUnit()) { + +irWhen( + builtIns.unitType, listOf( + callBranch, irElseBranch(frameCall(this, "resumeAfter", irInt(site.index), nullSerializer())) + ) + ) + +irReturn(zero(returnType)) + } else { + val after = frameCall(this, "resumeAfter", irInt(site.index), serializer(this, type)) + val value = irAs(irWhen(builtIns.anyNType, listOf(callBranch, irElseBranch(after))), type) + +irReturn(returning(this, value, type)) + } + } + val fresh = builder.irBlock(resultType = builtIns.nothingType) { + val arguments = call.arguments.map { argument -> argument?.let { irTemporary(it, "argument") } } + arguments.forEachIndexed { index, temporary -> call.arguments[index] = temporary?.let(::irGet) } + val handOver = irBlock(resultType = builtIns.nothingType) { + val tag = irTemporary(irCallConstructor(symbols.compoundTagConstructor, emptyList())) + callee.parameters.forEachIndexed { index, parameter -> + if (parameter.kind == IrParameterKind.DispatchReceiver) return@forEachIndexed + +with(stages) { + callObject( + symbols.put, + irGet(tag), + irString(stages.argumentName(callee, parameter)), + serializer(parameter.type), + irGet(arguments[index]!!) + ) + } + } + +frameCall(this, "tailCall", irGet(arguments[0]!!), irString(stages.stageName(callee)), irGet(tag)) + +irReturn(zero(returnType)) + } + val ordinary = irBlock(resultType = builtIns.nothingType) { + +frameCall(this, "beginCall", irInt(site.index)) + saveLocals(site) + val completed = frameCall(this, "completeCall", irInt(site.index), call) + if (returnType.isUnit()) { + +completed + +irReturn(zero(returnType)) + } else +irReturn(returning(this, irAs(completed, type), type)) + } + +irIfThenElse(builtIns.nothingType, frameCall(this, "canTailCall"), handOver, ordinary) + } + return builder.irWhen( + builtIns.unitType, listOf( + builder.irBranch( + frameCall( + builder, "resumesInto", builder.irInt(site.callPosition), builder.irInt(site.afterPosition) + ), resumed + ), + builder.irBranch(frameCall(builder, "isResuming"), zero(builtIns.unitType)), + builder.irElseBranch(fresh), + ) + ) + } + + /** Every `return` of the stage that is not a stage call hands its value to the frame as well. */ + private fun reportReturns(statements: List) { + val transformer = object : IrElementTransformerVoid() { + override fun visitFunction(declaration: IrFunction): IrStatement = declaration + + override fun visitReturn(expression: IrReturn): IrExpression { + expression.transformChildrenVoid(this) + val site = (expression.value as? IrCall)?.let(layout::site) + if (expression.returnTargetSymbol == function.symbol && site == null) { + expression.value = + returning(stages.builder(function.symbol, expression), expression.value, expression.value.type) + } + return expression + } + } + IrBlockImpl( + function.startOffset, function.endOffset, builtIns.unitType, null, statements + ).transformChildrenVoid(transformer) + } + + /** Hands the value a stage returns to its frame, which keeps it for a caller resumed after a restart. */ + private fun returning(builder: IrBuilderWithScope, value: IrExpression, type: IrType): IrExpression = + builder.irAs(frameCall(builder, "returning", value, serializer(builder, returnType)), type) + + private fun IrBlockBuilder.saveLocals(site: StageSite) { + site.live.forEach { variable -> + +frameCall( + this, "save", irString(layout.keys.getValue(variable)), serializer(this, variable.type), irGet(variable) + ) + } + } + + private fun siteOf(statement: IrStatement): StageSite? { + val call = when (statement) { + is IrCall -> statement + is IrTypeOperatorCall if statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT -> statement.argument as? IrCall + is IrVariable -> statement.initializer as? IrCall + is IrSetValue -> statement.value as? IrCall + is IrReturn -> statement.value as? IrCall + else -> null + } ?: return null + return layout.site(call) + } + + private fun frameCall(builder: IrBuilderWithScope, name: String, vararg arguments: IrExpression): IrCall = + builder.irCall(symbols.frameFunction(name, arguments.size)).apply { + this.arguments[0] = builder.irGet(frame) + arguments.forEachIndexed { index, argument -> this.arguments[index + 1] = argument } + } + + private fun serializer(builder: IrBuilderWithScope, type: IrType): IrExpression = + with(stages) { builder.serializer(type) } + + private fun not(builder: IrBuilderWithScope, value: IrExpression): IrExpression = + builder.irCall(builtIns.booleanNotSymbol).apply { arguments[0] = value } + + private fun and(builder: IrBuilderWithScope, left: IrExpression, right: IrExpression): IrExpression = + builder.irIfThenElse(builtIns.booleanType, left, right, builder.irFalse(), IrStatementOrigin.ANDAND) + + private fun or(builder: IrBuilderWithScope, left: IrExpression, right: IrExpression): IrExpression = + builder.irIfThenElse(builtIns.booleanType, left, builder.irTrue(), right, IrStatementOrigin.OROR) + + private fun nullSerializer(): IrExpression = + IrConstImpl.constNull(function.startOffset, function.endOffset, symbols.serializerType) + + private fun zero(type: IrType): IrExpression = + if (type.isUnit()) stages.builder(function.symbol, function).irGetObject(builtIns.unitClass) + else stages.blank(type, function.startOffset, function.endOffset) + + /** Generates the entry the runtime calls to start or resume the stage with saved arguments. */ + fun entry(name: String): IrSimpleFunction { + val script = stages.script + val entry = stages.context.irFactory.buildFun { + startOffset = function.startOffset + endOffset = function.endOffset + this.name = Name.identifier($$"stage$entry$$${function.name.asString()}") + returnType = builtIns.anyNType + isSuspend = true + visibility = DescriptorVisibilities.PUBLIC + } + entry.parent = script + val receiver = entry.buildReceiverParameter { type = script.defaultType } + entry.parameters = listOf(receiver) + val args = entry.addValueParameter("args", symbols.compoundTagType) + entry.annotations = listOf(stages.entryAnnotation(name)) + entry.body = stages.builder(entry.symbol, function).irBlockBody { + val call = irCall(function.symbol).apply { + function.parameters.forEachIndexed { index, parameter -> + arguments[index] = if (parameter.kind == IrParameterKind.DispatchReceiver) irGet(receiver) else { + val value = with(stages) { + callObject( + symbols.get, + irGet(args), + irString(stages.argumentName(function, parameter)), + serializer(parameter.type) + ) + } + irAs(value, parameter.type) + } + } + } + if (returnType.isUnit()) { + +call + +irReturn(irGetObject(builtIns.unitClass)) + } else +irReturn(call) + } + return entry + } + + /** + * Generates the call of a stage made from a lambda: the runtime passes the [captures] it saved and the + * parameters of the call, which the stage takes after the captured values. + */ + fun lambdaCall(name: String, captures: Int): IrSimpleFunction { + val script = stages.script + val call = stages.context.irFactory.buildFun { + startOffset = function.startOffset + endOffset = function.endOffset + this.name = Name.identifier("stage\$call\$${function.name.asString()}") + returnType = builtIns.anyNType + isSuspend = true + visibility = DescriptorVisibilities.PUBLIC + } + call.parent = script + val receiver = call.buildReceiverParameter { type = script.defaultType } + call.parameters = listOf(receiver) + val captured = call.addValueParameter("captured", symbols.compoundTagType) + val arguments = stages.arguments(function) + val parameters = arguments.drop(captures).map { call.addValueParameter(it.name, it.type) } + call.annotations = listOf(stages.callAnnotation(name)) + call.body = stages.builder(call.symbol, function).irBlockBody { + val invocation = irCall(function.symbol).apply { + this.arguments[0] = irGet(receiver) + arguments.forEachIndexed { index, parameter -> + this.arguments[index + 1] = if (index < captures) { + val value = with(stages) { + callObject( + symbols.get, + irGet(captured), + irString(stages.argumentName(function, parameter)), + serializer(parameter.type) + ) + } + irAs(value, parameter.type) + } else irGet(parameters[index - captures]) + } + } + if (returnType.isUnit()) { + +invocation + +irReturn(irGetObject(builtIns.unitClass)) + } else +irReturn(invocation) + } + return call + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt new file mode 100644 index 000000000..38245eb9f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageScope.kt @@ -0,0 +1,134 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.descriptors.InlineClassRepresentation +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrValueDeclaration +import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrTypeOperatorCallImpl +import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.IrSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.types.getClass +import org.jetbrains.kotlin.ir.types.isNullable +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.defaultValueForType +import org.jetbrains.kotlin.ir.util.getValueArgument +import org.jetbrains.kotlin.ir.util.isAnnotation +import org.jetbrains.kotlin.ir.util.isSuspend +import org.jetbrains.kotlin.name.Name + +/** What the stage passes over one script class share: symbols, error reporting, the script itself. */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageScope( + val context: IrPluginContext, + val symbols: StageSymbols, + val types: StageTypes, + val reporter: StageReporter, + val script: IrClass, +) { + /** + * What stands for a value of [type] that does not exist yet. A value class is its underlying value to the JVM, so + * null would not fit where it goes, and building one would run its checks. + */ + fun blank(type: IrType, start: Int, end: Int): IrExpression { + val kind = type.takeUnless { it.isNullable() }?.getClass()?.valueClassRepresentation + val underlying = (kind as? InlineClassRepresentation)?.underlyingType + ?: return IrConstImpl.defaultValueForType(start, end, type) + return IrTypeOperatorCallImpl(start, end, type, IrTypeOperator.IMPLICIT_CAST, type, blank(underlying, start, end)) + } + + fun builder(symbol: IrSymbol, at: IrElement): DeclarationIrBuilder = + DeclarationIrBuilder(context, symbol, at.startOffset, at.endOffset) + + /** Whether [function] is a stage: a suspend function marked `@State`. */ + fun isSuspendState(function: IrFunction): Boolean = function.isSuspend && stateAnnotation(function) != null + + /** The name saves know a stage by. */ + fun stageName(function: IrFunction): String { + val explicit = stateAnnotation(function)?.getValueArgument(Name.identifier("name")) as? IrConst + return (explicit?.value as? String)?.takeIf(String::isNotBlank) ?: function.name.asString() + } + + /** What [call] is from the point of view of the stage it is in, or null when it is an ordinary call. */ + fun siteKind(call: IrCall): SiteKind? { + val callee = call.symbol.owner + return when { + symbols.isCheckpoint(callee) -> SiteKind.CHECKPOINT + isSuspendState(callee) -> SiteKind.STAGE + isAction(callee) -> SiteKind.ACTION + callee.name.asString() == "invoke" && call.dispatchReceiver?.type?.let(types::isStageLambda) == true -> SiteKind.LAMBDA + else -> null + } + } + + /** + * Whether [function] is an action a stage can call as a boundary: a suspend `@Action` or `@ActionAlias` that is + * not a checkpoint label, which only runs inside `checkpoint(name) { }`. + */ + private fun isAction(function: IrFunction): Boolean = function.isSuspend && + function.annotations.any { it.isAnnotation(StageSymbols.ACTION) || it.isAnnotation(StageSymbols.ACTION_ALIAS) } && + function.parameters.none { it.type.classOrNull == symbols.checkpointScope } + + /** The parameters a stage saves as its arguments: all of them but the script it belongs to. */ + fun arguments(function: IrFunction): List = + function.parameters.filter { it.kind != IrParameterKind.DispatchReceiver } + + /** The name [parameter] of [function] is saved under: its own name, `this` for a receiver. */ + fun argumentName(function: IrFunction, parameter: IrValueParameter): String = when { + parameter.kind == IrParameterKind.ExtensionReceiver -> "this" + parameter.name.isSpecial -> "argument${function.parameters.indexOf(parameter)}" + else -> parameter.name.asString() + } + + /** Whether [value] is the script instance itself. */ + fun isScriptThis(value: IrValueDeclaration): Boolean = + value is IrValueParameter && value.type.classOrNull == script.symbol + + /** The serializer of [type]; function types are kept as the stage lambdas they must be. */ + fun IrBuilderWithScope.serializer(type: IrType): IrExpression = irCall(symbols.storySerializer).apply { + typeArguments[0] = if (types.isStageLambda(type) || types.isFunction(type)) symbols.stageLambda.owner.defaultType else type + } + + /** Calls [function] with [arguments] for all its parameters in order, receivers first. */ + fun IrBuilderWithScope.call(function: IrSimpleFunctionSymbol, vararg arguments: IrExpression?): IrCall = + irCall(function).apply { arguments.forEachIndexed { index, argument -> this.arguments[index] = argument } } + + /** Calls a member of an object such as `StoryValues`. */ + fun IrBuilderWithScope.callObject(function: IrSimpleFunctionSymbol, vararg arguments: IrExpression?): IrCall { + val owner = function.owner.parent as IrClass + return call(function, irGetObject(owner.symbol), *arguments) + } + + fun stateAnnotation(name: String): IrAnnotation = annotation(symbols.stateConstructor, name) + + fun entryAnnotation(name: String): IrAnnotation = annotation(symbols.stageEntry, name) + + fun callAnnotation(name: String): IrAnnotation = annotation(symbols.stageCall, name) + + fun resultAnnotation(name: String): IrAnnotation = annotation(symbols.stageResult, name) + + fun scriptAnnotation(path: String): IrAnnotation = annotation(symbols.stageScript, path) + + private fun annotation(constructor: IrConstructorSymbol, value: String): IrAnnotation = + builder(constructor, script).run { irAnnotation(constructor).apply { arguments[0] = irString(value) } } + + private fun stateAnnotation(function: IrFunction): IrConstructorCall? = + function.annotations.firstOrNull { it.isAnnotation(StageSymbols.STATE) } +} + +/** + * The calls a stage is cut at. An [ACTION] call is a boundary too: a restart does not perform a finished action + * again, and finishes one it interrupted. + */ +internal enum class SiteKind { STAGE, LAMBDA, CHECKPOINT, ACTION } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt new file mode 100644 index 000000000..7eec3a511 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageSymbols.kt @@ -0,0 +1,116 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrProperty +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.symbols.IrClassSymbol +import org.jetbrains.kotlin.ir.symbols.IrConstructorSymbol +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.constructors +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.getSimpleFunction +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.ir.stages.StageSymbols.Companion.find + +/** + * The declarations of the story runtime that stage code is rewritten against. The runtime lives in the + * Narrative addon; without it on classpath there are no stages to compile and [find] returns null. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageSymbols private constructor(context: IrPluginContext, val state: IrClassSymbol) { + private val finder = context.finderForBuiltins() + val builtIns = context.irBuiltIns + + val frame: IrClassSymbol = storyClass("StoryFrame") + val frames: IrClassSymbol = storyClass("StoryFrames") + val values: IrClassSymbol = storyClass("StoryValues") + val instances: IrClassSymbol = storyClass("StoryInstances") + val loops: IrClassSymbol = storyClass("StoryLoops") + val stageLambda: IrClassSymbol = storyClass("StageLambda") + val storyBox: IrClassSymbol = storyClass("StoryBox") + val boxes: IrClassSymbol = storyClass("StoryBoxes") + val box: IrSimpleFunctionSymbol = member(boxes, "box") + val boxGetter: IrSimpleFunctionSymbol = boxValue().getter!!.symbol + val boxSetter: IrSimpleFunctionSymbol = boxValue().setter!!.symbol + val stageEntry: IrConstructorSymbol = storyClass("StageEntry").constructors.single() + val stageCall: IrConstructorSymbol = storyClass("StageCall").constructors.single() + val stageResult: IrConstructorSymbol = storyClass("StageResult").constructors.single() + val stageScript: IrConstructorSymbol = storyClass("StageScript").constructors.single() + val stateConstructor: IrConstructorSymbol = state.constructors.single() + val checkpoints: Set = storyFunctions("checkpoint") + val actionBodies: Set = storyFunctions("action") + storyFunctions("label") + val checkpointScope: IrClassSymbol = storyClass("CheckpointScope") + val storySerializer: IrSimpleFunctionSymbol = storyFunction("storySerializer") + + val compoundTag: IrClassSymbol = finder.findClass(ClassId.topLevel(FqName("net.minecraft.nbt.CompoundTag"))) + ?: error("CompoundTag is not on the script classpath") + val compoundTagConstructor: IrConstructorSymbol = + compoundTag.constructors.single { constructor -> constructor.owner.parameters.none { it.kind == IrParameterKind.Regular } } + + /** `KSerializer`, what [storySerializer] returns. */ + val serializerType: IrType = + finder.findClass(ClassId.topLevel(FqName("kotlinx.serialization.KSerializer")))?.typeWith(builtIns.anyNType) + ?: error("kotlinx.serialization is not on the script classpath") + + val frameType: IrType get() = frame.owner.defaultType + val sequence: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("kotlin.sequences.Sequence"))) + val compoundTagType: IrType get() = compoundTag.owner.defaultType + val intProgression: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("kotlin.ranges.IntProgression"))) + + /** The member [name] of the frame that takes [arity] arguments. */ + fun frameFunction(name: String, arity: Int): IrSimpleFunctionSymbol = + frame.owner.declarations.filterIsInstance() + .firstOrNull { it.name.asString() == name && it.parameters.size == arity + 1 }?.symbol + ?: error("StoryFrame.$name with $arity arguments is missing from the story runtime") + + val enter: IrSimpleFunctionSymbol = member(frames, "enter") + val put: IrSimpleFunctionSymbol = member(values, "put") + val get: IrSimpleFunctionSymbol = member(values, "get") + val keyOf: IrSimpleFunctionSymbol = member(instances, "keyOf") + val loopCount: IrSimpleFunctionSymbol = member(loops, "count") + val loopElement: IrSimpleFunctionSymbol = member(loops, "element") + val loopSnapshot: IrSimpleFunctionSymbol = member(loops, "snapshot") + + /** The constructor of the stage lambda that takes [arity] parameters, or null when there is none so large. */ + fun stageLambdaConstructor(arity: Int): IrConstructorSymbol? = + finder.findClass(ClassId(PACKAGE, Name.identifier("StageLambda$arity")))?.constructors?.single() + + fun isCheckpoint(function: IrFunction): Boolean = function.symbol in checkpoints + + /** Whether [function] is `action { }` or `label { }`, the body of an action. */ + fun isActionBody(function: IrFunction): Boolean = function.symbol in actionBodies + + private fun storyClass(name: String): IrClassSymbol = + finder.findClass(ClassId(PACKAGE, Name.identifier(name))) ?: error("$name is missing from the story runtime") + + private fun storyFunction(name: String): IrSimpleFunctionSymbol = + storyFunctions(name).firstOrNull() ?: error("$name is missing from the story runtime") + + private fun storyFunctions(name: String): Set = + finder.findFunctions(CallableId(PACKAGE, Name.identifier(name))).toSet() + + private fun boxValue(): IrProperty = storyBox.owner.declarations.filterIsInstance().single { it.name.asString() == "value" } + + private fun member(owner: IrClassSymbol, name: String): IrSimpleFunctionSymbol = + owner.getSimpleFunction(name) ?: error("${owner.owner.name}.$name is missing from the story runtime") + + companion object { + val PACKAGE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story") + val STATE: FqName = PACKAGE.child(Name.identifier("State")) + val ACTION: FqName = PACKAGE.child(Name.identifier("Action")) + val ACTION_ALIAS: FqName = PACKAGE.child(Name.identifier("ActionAlias")) + val UNSAVED: FqName = PACKAGE.child(Name.identifier("Unsaved")) + + fun find(context: IrPluginContext): StageSymbols? = + context.finderForBuiltins().findClass(ClassId(PACKAGE, Name.identifier("State"))) + ?.let { StageSymbols(context, it) } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt new file mode 100644 index 000000000..9a73f7118 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageTypes.kt @@ -0,0 +1,61 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.symbols.IrClassSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrSimpleType +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.IrTypeProjection +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.util.* +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageValueTypes + +/** + * Says which values a story can keep. It mirrors what `StoryValues` writes at runtime: whatever kotlinx + * serialization or the engine's NBT format can write, entities as references, and stage lambdas. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageTypes(context: IrPluginContext) { + private val finder = context.finderForBuiltins() + private val entity: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("net.minecraft.world.entity.Entity"))) + private val tag: IrClassSymbol? = finder.findClass(ClassId.topLevel(FqName("net.minecraft.nbt.Tag"))) + + /** Why a story cannot keep a value of [type], or null when it can. */ + fun problem(type: IrType): String? { + if (isStageLambda(type)) return null + val simple = type as? IrSimpleType ?: return "${type.render()} cannot be saved" + val klass = simple.classOrNull?.owner ?: return "${type.render()} is a type parameter, which cannot be saved" + if (entity != null && type.isSubtypeOfClass(entity)) return null + if (tag != null && type.isSubtypeOfClass(tag)) return null + val name = klass.kotlinFqName.asString() + if (name in StageValueTypes.BASIC || name == StageValueTypes.STORY_HANDLE) return null + if (isFunction(type)) { + return "a function value cannot be saved; pass a lambda to a @State parameter instead" + } + if (klass.isPrimitiveArray()) return null + if (name == "kotlin.Array" || name == StageValueTypes.STORY_BOX || name in StageValueTypes.CONTAINERS || klass.isSerializable() || name in StageValueTypes.CONTEXTUAL) { + return simple.arguments.firstNotNullOfOrNull { argument -> + (argument as? IrTypeProjection)?.type?.let(::problem) + ?: if (argument is IrTypeProjection) null else "a star projection cannot be saved" + } + } + return "${type.render()} cannot be saved: it is not @Serializable" + } + + /** Whether [type] is a function type; a story keeps such values only as stage lambdas. */ + fun isFunction(type: IrType): Boolean = type.classOrNull?.owner?.kotlinFqName?.asString().orEmpty().let { + it.startsWith("kotlin.Function") || it.startsWith("kotlin.coroutines.SuspendFunction") || it.startsWith("kotlin.reflect.KFunction") + } + + /** Whether [type] is a function type marked `@State`, whose values are stage lambdas. */ + fun isStageLambda(type: IrType): Boolean = type.annotations.any { it.isAnnotation(StageSymbols.STATE) } || + type.classOrNull?.owner?.kotlinFqName?.asString().orEmpty().startsWith(StageValueTypes.STAGE_LAMBDA) + + private fun IrClass.isSerializable(): Boolean = kind == ClassKind.ENUM_CLASS || hasAnnotation(StageValueTypes.SERIALIZABLE) + + private fun IrClass.isPrimitiveArray(): Boolean = kotlinFqName.asString() in StageValueTypes.PRIMITIVE_ARRAYS +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt new file mode 100644 index 000000000..f5ef93076 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/stages/StageVarBoxes.kt @@ -0,0 +1,149 @@ +package ru.hollowhorizon.hollowengine.common.plugin.ir.stages + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irGetObject +import org.jetbrains.kotlin.ir.builders.irString +import org.jetbrains.kotlin.ir.declarations.* +import org.jetbrains.kotlin.ir.declarations.impl.IrVariableImpl +import org.jetbrains.kotlin.ir.expressions.* +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.symbols.impl.IrVariableSymbolImpl +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.kotlinFqName +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.name.Name +import ru.hollowhorizon.hollowengine.common.plugin.stages.StageMessages + +/** + * Lets `@State` lambdas share the vars of the stage they are written in. Such a var becomes a box + * and every read and write goes through its value, so the stage and lambda see one value, + * which the story saves once. Runs before the lambdas are lifted, which then capture the box. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class StageVarBoxes(private val stages: StageScope) { + private val symbols = stages.symbols + + /** A `@State` lambda found in a stage and the function it is passed to. */ + private class Lambda(val function: IrSimpleFunction, val callee: IrFunction) + + fun compile() { + val lambdas = ArrayList() + stages.script.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitCall(expression: IrCall) { + super.visitCall(expression) + val callee = expression.symbol.owner + callee.parameters.forEach { parameter -> + if (!stages.types.isStageLambda(parameter.type)) return@forEach + val lambda = expression.arguments[parameter] as? IrFunctionExpression ?: return@forEach + lambdas += Lambda(lambda.function, callee) + } + } + }) + val owners = stages.script.declarations.filterIsInstance() + .filter(stages::isSuspendState) + lambdas.map { it.function } + owners.forEach { owner -> box(owner, lambdas.filter { it.function !== owner && it.function.isInside(owner) }) } + } + + private fun box(owner: IrSimpleFunction, lambdas: List) { + val shared = LinkedHashMap() + val locals = StageLocalFunctions(stages) + lambdas.forEach { lambda -> + val inside = declaredIn(lambda.function) + val reached = listOf(lambda.function) + locals.reachableFrom(lambda.function) + reached.forEach { function -> + function.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) = element.acceptChildrenVoid(this) + + override fun visitGetValue(expression: IrGetValue) = share(expression.symbol.owner) + + override fun visitSetValue(expression: IrSetValue) { + super.visitSetValue(expression) + share(expression.symbol.owner) + } + + private fun share(value: Any) { + if (value is IrVariable && value.isVar && value !in inside && value.parent === owner) { + shared.putIfAbsent(value, lambda) + } + } + }) + } + } + val boxes = shared.filter { (variable, lambda) -> canShare(variable, lambda) }.keys.associateWith(::boxOf) + if (boxes.isEmpty()) return + owner.body?.transformChildrenVoid(object : IrElementTransformerVoid() { + override fun visitVariable(declaration: IrVariable): IrStatement { + declaration.transformChildrenVoid(this) + val box = boxes[declaration] ?: return declaration + box.initializer = stages.builder(owner.symbol, declaration).run { + with(stages) { + irCall(symbols.box, box.type).apply { + typeArguments[0] = declaration.type + arguments[0] = irGetObject(symbols.boxes) + arguments[1] = irString(declaration.name.asString()) + arguments[2] = serializer(declaration.type) + arguments[3] = declaration.initializer + } + } + } + return box + } + + override fun visitGetValue(expression: IrGetValue): IrExpression { + val box = boxes[expression.symbol.owner] ?: return expression + return stages.builder(owner.symbol, expression).run { + irCall(symbols.boxGetter, expression.type).apply { arguments[0] = irGet(box) } + } + } + + override fun visitSetValue(expression: IrSetValue): IrExpression { + expression.transformChildrenVoid(this) + val box = boxes[expression.symbol.owner] ?: return expression + return stages.builder(owner.symbol, expression).run { + irCall(symbols.boxSetter, expression.type).apply { + arguments[0] = irGet(box) + arguments[1] = expression.value + } + } + } + }) + } + + private fun canShare(variable: IrVariable, lambda: Lambda): Boolean { + val problem = when { + variable.initializer == null -> "give it a value where it is declared" + isNewStory(lambda.callee) -> "a story started here keeps copies of what it captures; pass the value instead" + else -> stages.types.problem(variable.type)?.let { "its value is saved, but $it" } + } ?: return true + stages.reporter.error(variable, StageMessages.sharedVar(variable.name.asString(), problem)) + return false + } + + /** `launchStory` and `StoryRuns.launch` start a story of their own, which cannot share the vars of this one. */ + private fun isNewStory(callee: IrFunction): Boolean { + val name = callee.name.asString() + if (name != "launchStory" && name != "launch") return false + val owner = (callee.parent as? IrClass)?.kotlinFqName ?: callee.kotlinFqName.parent() + return owner.asString().startsWith(StageSymbols.PACKAGE.asString()) + } + + private fun boxOf(variable: IrVariable): IrVariable = IrVariableImpl( + variable.startOffset, + variable.endOffset, + IrDeclarationOrigin.DEFINED, + IrVariableSymbolImpl(), + Name.identifier($$"$${variable.name.asString()}$box"), + symbols.storyBox.typeWith(variable.type), + isVar = false, + isConst = false, + isLateinit = false, + ).also { it.parent = variable.parent } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt deleted file mode 100644 index f0fabe673..000000000 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/ir/transformers/StateCallTransformer.kt +++ /dev/null @@ -1,120 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.plugin.ir.transformers - -import org.jetbrains.kotlin.backend.common.pop -import org.jetbrains.kotlin.backend.common.push -import org.jetbrains.kotlin.ir.IrStatement -import org.jetbrains.kotlin.ir.builders.irCall -import org.jetbrains.kotlin.ir.builders.irString -import org.jetbrains.kotlin.ir.declarations.IrFunction -import org.jetbrains.kotlin.ir.declarations.IrParameterKind -import org.jetbrains.kotlin.ir.expressions.IrCall -import org.jetbrains.kotlin.ir.expressions.IrConst -import org.jetbrains.kotlin.ir.expressions.IrConstructorCall -import org.jetbrains.kotlin.ir.expressions.IrExpression -import org.jetbrains.kotlin.ir.expressions.impl.IrReturnImpl -import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol -import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI -import org.jetbrains.kotlin.ir.util.getValueArgument -import org.jetbrains.kotlin.ir.util.isAnnotation -import org.jetbrains.kotlin.ir.util.isSuspend -import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid -import org.jetbrains.kotlin.name.CallableId -import org.jetbrains.kotlin.name.FqName -import org.jetbrains.kotlin.name.Name -import ru.hollowhorizon.hollowengine.common.plugin.ir.StateIrGenerationExtension.Companion.pluginContext -import ru.hollowhorizon.hollowengine.common.plugin.ir.util.builder - -private val transitionCallableId = CallableId( - packageName = FqName("ru.hollowhorizon.hollowengine.common.scripting.state"), - callableName = Name.identifier("transition"), -) - -private val transitionSymbol: IrSimpleFunctionSymbol by lazy { - pluginContext.finderForBuiltins() - .findFunctions(transitionCallableId) - .singleOrNull { symbol -> - val function = symbol.owner - - function.isSuspend && - function.parameters.any { parameter -> - parameter.kind == IrParameterKind.Regular && - parameter.name.asString() == "target" && - parameter.type == pluginContext.irBuiltIns.stringType - } - } - ?: error("Cannot find suspend function $transitionCallableId") -} - -class StateCallTransformer : IrElementTransformerVoid() { - private val stateFunctions = mutableListOf() - - override fun visitFunction(declaration: IrFunction): IrStatement { - val isStateFunction = declaration.hasStateAnnotation() && declaration.isSuspend - - if (isStateFunction) stateFunctions.push(declaration) - - val result = super.visitFunction(declaration) - - if (isStateFunction) stateFunctions.pop() - - return result - } - - @OptIn(UnsafeDuringIrConstructionAPI::class) - override fun visitCall(expression: IrCall): IrExpression { - val target = expression.symbol.owner - - val stateName = target.getStateNameOrNull() - ?: return super.visitCall(expression) - - if (!target.isSuspend) { - return super.visitCall(expression) - } - - val currentStateFunction = stateFunctions.lastOrNull() - ?: return super.visitCall(expression) - - val builder = currentStateFunction.builder() - - val transitionCall = builder.irCall(transitionSymbol).apply { - val targetParameter = symbol.owner.parameters.single { - it.kind == IrParameterKind.Regular && - it.name.asString() == "target" - } - - arguments[targetParameter] = builder.irString(stateName) - } - - return IrReturnImpl( - expression.startOffset, - expression.endOffset, - pluginContext.irBuiltIns.anyNType, - currentStateFunction.symbol, - transitionCall, - ) - } -} - -private val stateAnnotationFqName = FqName("ru.hollowhorizon.hollowengine.common.scripting.annotations.State") - -private fun IrFunction.hasStateAnnotation(): Boolean { - return annotations.any { it.isAnnotation(stateAnnotationFqName) } -} - -private fun IrFunction.getStateNameOrNull(): String? { - val annotation = annotations.firstOrNull { it.isAnnotation(stateAnnotationFqName) } - ?: return null - - val explicitName = annotation.getStringArgumentByName("name") - - return explicitName - ?.takeIf { it.isNotBlank() } - ?: name.asString() -} - -@OptIn(UnsafeDuringIrConstructionAPI::class) -private fun IrConstructorCall.getStringArgumentByName(parameterName: String): String? { - val argument = getValueArgument(Name.identifier(parameterName)) ?: return null - - return (argument as? IrConst)?.value as? String -} \ No newline at end of file diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt new file mode 100644 index 000000000..dbbb9c65e --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageDiagnostics.kt @@ -0,0 +1,38 @@ +package ru.hollowhorizon.hollowengine.common.plugin.stages + +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.diagnostics.KtDiagnosticFactory1 +import org.jetbrains.kotlin.diagnostics.KtDiagnosticFactoryToRendererMap +import org.jetbrains.kotlin.diagnostics.KtDiagnosticsContainer +import org.jetbrains.kotlin.diagnostics.Severity +import org.jetbrains.kotlin.diagnostics.SourceElementPositioningStrategies +import org.jetbrains.kotlin.diagnostics.rendering.BaseDiagnosticRendererFactory +import org.jetbrains.kotlin.diagnostics.rendering.CommonRenderers + +/** Diagnostic factories shared by Kotlin's front end, the built-in IDE, and IR validation. */ +internal object StageDiagnostics : KtDiagnosticsContainer() { + val STAGE_ERROR = KtDiagnosticFactory1( + "HOLLOWENGINE_STAGE_ERROR", + Severity.ERROR, + SourceElementPositioningStrategies.DEFAULT, + PsiElement::class, + Messages, + ) + + val STAGE_WARNING = KtDiagnosticFactory1( + "HOLLOWENGINE_STAGE_WARNING", + Severity.WARNING, + SourceElementPositioningStrategies.DEFAULT, + PsiElement::class, + Messages, + ) + + override fun getRendererFactory(): BaseDiagnosticRendererFactory = Messages + + object Messages : BaseDiagnosticRendererFactory() { + override val MAP: KtDiagnosticFactoryToRendererMap by KtDiagnosticFactoryToRendererMap("HollowEngineStages") { map -> + map.put(STAGE_ERROR, "{0}", CommonRenderers.STRING) + map.put(STAGE_WARNING, "{0}", CommonRenderers.STRING) + } + } +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt new file mode 100644 index 000000000..b1397430f --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageMessages.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.common.plugin.stages + +/** Messages shared by front-end validation and the IR safety checks. */ +internal object StageMessages { + const val FUNCTION_VALUE = "A @State parameter needs a lambda written in place or another @State parameter; a regular function value has no saved stage" + const val NESTED_CALL = "A stage cannot be called from a lambda inside a stage: its place could not be saved. Use async(name) { } or call it directly" + const val HANDLER_CALL = "A stage cannot be called in catch or finally: keep what you need in a variable and call it after the try" + const val CHECKPOINT_OUTSIDE = "checkpoint() must be called directly in a @State function" + const val TOP_LEVEL = "A state function must be declared at the top level of a script" + const val SUSPEND = "A state function must be a suspend function" + const val TYPE_PARAMETERS = "A stage cannot have type parameters: its arguments are saved by their declared types" + + fun localClosure(description: String) = + "A @State lambda cannot use $description declared outside it: its closure is not saved. Move it to the top level of the script and pass saved values as explicit arguments, or declare it inside the lambda" + + fun mutableCapture(name: String) = + "A @State lambda can share 'var $name' only with the @State function or lambda that declares it directly; declare it there or copy it into a val" + + fun localFunction(name: String, problem: String) = + "A @State lambda calls local function '$name', which is copied out of its scope with what it uses, but $problem" + + fun sharedVar(name: String, problem: String) = "A @State lambda shares 'var $name' with the stage that declares it, but $problem" + + fun assignCapture(name: String) = "A @State lambda cannot assign '$name': it keeps a saved copy" + + fun unsavedCapture(name: String, problem: String) = + "A @State lambda saves what it captures, but '$name' cannot be saved: $problem" +} diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt new file mode 100644 index 000000000..c92de9f02 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/plugin/stages/StageValueTypes.kt @@ -0,0 +1,50 @@ +package ru.hollowhorizon.hollowengine.common.plugin.stages + +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** Value categories shared by FIR validation and IR persistence analysis. */ +internal object StageValueTypes { + val SERIALIZABLE = FqName("kotlinx.serialization.Serializable") + val SERIALIZABLE_ID = ClassId.topLevel(SERIALIZABLE) + val STAGE_LAMBDA = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StageLambda")).asString() + + /** A handle of a run, saved as the path of the run whatever its result type. */ + val STORY_HANDLE = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StoryHandle")).asString() + + /** The box a shared var becomes; it is saved as its value. */ + val STORY_BOX = FqName("ru.hollowhorizon.hollowengine.addons.narrative.story").child(Name.identifier("StoryBox")).asString() + + val PRIMITIVE_ARRAYS = setOf( + "kotlin.IntArray", "kotlin.LongArray", "kotlin.ShortArray", "kotlin.ByteArray", "kotlin.FloatArray", + "kotlin.DoubleArray", "kotlin.BooleanArray", "kotlin.CharArray", + ) + + val BASIC = setOf( + "kotlin.Int", "kotlin.Long", "kotlin.Short", "kotlin.Byte", "kotlin.Float", "kotlin.Double", + "kotlin.Boolean", "kotlin.Char", "kotlin.String", "kotlin.Unit", + ) + + val CONTAINERS = setOf( + "kotlin.collections.List", "kotlin.collections.MutableList", "kotlin.collections.Collection", + "kotlin.collections.Set", "kotlin.collections.MutableSet", + "kotlin.collections.Map", "kotlin.collections.MutableMap", + "kotlin.collections.Map.Entry", "kotlin.collections.MutableMap.MutableEntry", + "kotlin.Pair", "kotlin.Triple", + ) + + /** Classes the engine's NBT format writes without them being `@Serializable`. */ + val CONTEXTUAL = setOf( + "java.util.UUID", + "net.minecraft.core.BlockPos", + "net.minecraft.resources.ResourceLocation", + "net.minecraft.world.phys.Vec3", + "net.minecraft.network.chat.Component", + "net.minecraft.world.item.ItemStack", + "org.joml.Matrix4f", + "org.joml.Vector3d", + "org.joml.Vector3f", + "ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef", + ) +} diff --git a/addons/compiler/src/test/kotlin/AnimationNameDiagnosticsTest.kt b/addons/compiler/src/test/kotlin/AnimationNameDiagnosticsTest.kt new file mode 100644 index 000000000..0dcbf710f --- /dev/null +++ b/addons/compiler/src/test/kotlin/AnimationNameDiagnosticsTest.kt @@ -0,0 +1,83 @@ +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.ide.session.diagnostic.animationNameDiagnostics +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import ru.hollowhorizon.hollowengine.common.scripting.ide.AnimationClips +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +abstract class ClipScript + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AnimationNameDiagnosticsTest { + private val fixture = AnalysisEnvironmentTestFixture { + ScriptingEnvironmentImpl( + javaHome = File(System.getProperty("java.home")), + classpath = System.getProperty("java.class.path").split(File.pathSeparator).filter(String::isNotBlank) + .map(::File).filter(File::exists).distinctBy { it.absoluteFile.normalize() }, + scriptTypes = listOf( + ScriptClassProvider( + extension = ".clips.kts", + baseClass = ClipScript::class.qualifiedName!! + ) + ), + mappings = Mappings.EMPTY, + ) + } + + @BeforeAll + fun startEnvironment() = fixture.start() + + @AfterEach + fun resetEnvironment() = fixture.reset() + + @AfterAll + fun closeEnvironment() = fixture.close() + + private val clips = object : AnimationClips { + override fun of(model: String) = if (model == "hero.glb") listOf("idle", "wave", "walk") else null + override fun modelOfNpc(id: String) = if (id == "village:hero") "hero.glb" else null + } + + private val api = """ + import ru.hollowhorizon.hollowengine.common.attachments.editor.* + + class Actor + fun spawn(@EditorModel model: String = "") = Actor() + fun find(@EditorNpcId id: String) = Actor() + fun Actor.play(@EditorAnimation animation: String) = Unit + """.trimIndent() + + private fun warnings(body: String): List { + val name = "scripts/main.clips.kts" + val text = "$api\n$body\n" + fixture.environment.analyzer.diagnostic(name, text) + val file = fixture.environment.analyzer.fileCache.getValue(name).file + return animationNameDiagnostics(file, clips).map { it.message } + } + + @Test + fun `a clip the model lacks is named along with those it may have meant`() { + val found = warnings("val hero = spawn(model = \"hero.glb\")\nhero.play(\"wav\")\nhero.play(\"wave\")") + assertEquals(listOf("This model has no animation \"wav\". Did you mean: wave?"), found) + } + + @Test + fun `the model of an NPC of the world comes through its id`() { + val found = warnings("find(\"village:hero\").play(\"dance\")") + assertEquals(listOf("This model has no animation \"dance\""), found) + } + + @Test + fun `a receiver of unknown origin says nothing`() { + assertEquals( + emptyList(), + warnings("var hero = spawn(model = \"hero.glb\")\nhero.play(\"dance\")\nspawn().play(\"dance\")") + ) + } +} diff --git a/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt b/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt index 725006d7c..6f14b60a0 100644 --- a/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt +++ b/addons/compiler/src/test/kotlin/CompilerPluginAnalysisTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.TestInstance import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic import java.io.File import kotlin.test.Test @@ -31,6 +32,7 @@ class CompilerPluginAnalysisTest { ScriptClassProvider( extension = "ui.kts", baseClass = "ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript", + defaultImports = listOf("ru.hollowhorizon.hollowengine.client.ui.*"), ), ), mappings = Mappings.EMPTY, @@ -172,6 +174,77 @@ class CompilerPluginAnalysisTest { } } + @Test + fun `ui modifier completions include extensions on the companion and chained values`() { + withEnvironment { environment -> + assertNoErrors(environment.analyzer.diagnostic("modifier-calls.ui.kts", """ + screen("test:modifier") { + content { + val modifier = Modifier.gap(4.px).padding(8.px) + } + } + """.trimIndent())) + + val receivers = listOf("Modifier", "Modifier.Companion", "Modifier.gap(4.px)", "UiModifier") + for ((index, receiver) in receivers.withIndex()) { + for (prefix in listOf("", "pa")) { + val aliasImport = if (receiver == "UiModifier") { + "import ru.hollowhorizon.hollowengine.client.ui.Modifier as UiModifier\n" + } else "" + val text = aliasImport + "val modifier = $receiver.$prefix" + val items = environment.analyzer.collectCompletions( + "modifier-$index-${prefix.length}.ui.kts", text, text.length, + ) + assertTrue(items.any { it.name == "padding" }, "$receiver.$prefix: $items") + if (prefix.isEmpty()) assertTrue(items.any { it.name == "gap" }, "$receiver: $items") + } + } + } + } + + @Test + fun `class receiver completions include applicable companion extensions without instance members`() { + withEnvironment { environment -> + val declarations = """ + open class ReceiverBase + object Instance : ReceiverBase() + class Receiver { + companion object : ReceiverBase() { + fun ownMember() = Unit + } + fun instanceOnly() = Unit + } + fun ReceiverBase.applicableExtension() = Unit + val ReceiverBase.extensionProperty: Int get() = 0 + fun String.unrelatedExtension() = Unit + """.trimIndent() + for (receiver in listOf("Receiver", "Instance")) { + val text = "$declarations\n$receiver." + val items = environment.analyzer.collectCompletions("$receiver-extensions.analysis.kts", text, text.length) + if (receiver == "Receiver") assertTrue(items.any { it.name == "ownMember" }, items.toString()) + assertTrue(items.any { it.name == "applicableExtension" }, items.toString()) + assertTrue(items.any { it.name == "extensionProperty" }, items.toString()) + assertFalse(items.any { it.name == "instanceOnly" || it.name == "unrelatedExtension" }, items.toString()) + } + val text = "$declarations\nReceiverBase." + val items = environment.analyzer.collectCompletions("no-companion.analysis.kts", text, text.length) + assertFalse(items.any { it.name == "applicableExtension" }, items.toString()) + } + } + + @Test + fun `companion receiver completions offer importable extensions`() { + withEnvironment { environment -> + val text = """ + import ru.hollowhorizon.hollowengine.client.ui.Modifier + val modifier = Modifier.pa + """.trimIndent() + val items = environment.analyzer.collectCompletions("importable-modifier.analysis.kts", text, text.length) + val declarations = items.filterIsInstance() + assertTrue(declarations.any { it.name == "padding" && it.import }, items.toString()) + } + } + private fun withEnvironment(block: (ScriptingEnvironmentImpl) -> Unit) { block(fixture.environment) } diff --git a/addons/compiler/src/test/kotlin/EditorStructureTest.kt b/addons/compiler/src/test/kotlin/EditorStructureTest.kt index 18918c943..617827883 100644 --- a/addons/compiler/src/test/kotlin/EditorStructureTest.kt +++ b/addons/compiler/src/test/kotlin/EditorStructureTest.kt @@ -150,12 +150,65 @@ class EditorStructureTest { assertTrue(analyzer.structure("example.kt", "fun call() {}").runTargets.isEmpty()) assertTrue(analyzer.structure("example.ui.kts", "").runTargets.isEmpty()) val text = - "import ru.hollowhorizon.hollowengine.common.scripting.annotations.State as Entry\n" + "object Names { const val ENTRY = \"Begin\" }\n@Entry(Names.ENTRY) suspend fun first() {}\n" + "@Entry suspend fun second() {}\n@Entry fun invalid() {}" + "import ru.hollowhorizon.hollowengine.addons.narrative.story.State as Entry\n" + "object Names { const val ENTRY = \"Begin\" }\n@Entry(Names.ENTRY) suspend fun first() {}\n" + "@Entry suspend fun second() {}\n@Entry fun invalid() {}" val targets = analyzer.structure("states.node.kts", text).runTargets assertEquals(listOf(null, "Begin", "second"), targets.map { it.state }) assertEquals(0, targets.first().offset) } + @Test + fun `story stages mark their boundaries, checkpoints and actions in the gutter`() { + val source = """ + import ru.hollowhorizon.hollowengine.addons.narrative.story.* + @Action suspend fun place(name: String) = action { perform { } } + @State suspend fun step() {} + @State suspend fun main() { + step() + checkpoint("arena") + place("x") + listOf(1).forEach { place("y") } + } + """.trimIndent() + val markers = analyzer.structure("Story.kts", source).markers + assertEquals(GutterMarkerKind.ACTION, markers.single { it.offset == source.indexOf("place(name") }.kind) + val stage = markers.single { it.kind == GutterMarkerKind.STAGE } + assertEquals(source.indexOf(" step()") + 4, stage.offset) + assertEquals(listOf("step"), stage.args) + assertEquals(listOf("arena"), markers.single { it.kind == GutterMarkerKind.CHECKPOINT }.args) + assertEquals(GutterMarkerKind.ACTION_CALL, markers.single { it.offset == source.indexOf("place(\"x") }.kind) + assertEquals(GutterMarkerKind.COROUTINE, markers.single { it.offset == source.indexOf("place(\"y") }.kind, "in a lambda it is no boundary") + } + + @Test + fun `rewind and temporary take the color of the checkpoint they lead to`() { + val source = """ + import ru.hollowhorizon.hollowengine.addons.narrative.story.* + @State suspend fun main() { + rewind() + checkpoint("arena") + temporary { } + rewind() + temporary(until = "boss") { } + rewind("arena") + checkpoint("boss") + } + """.trimIndent() + val markers = analyzer.structure("Colors.kts", source).markers + fun at(text: String, from: Int = 0) = markers.single { it.offset == source.indexOf(text, from) } + val arena = at("checkpoint(\"arena").color + val boss = at("checkpoint(\"boss").color + assertEquals(GutterMarkerKind.REWIND_START, at("rewind()").kind) + assertNotEquals(arena, boss) + assertEquals(boss, at("temporary {").color, "a temporary without until goes at the next checkpoint") + assertEquals(arena, at("rewind()", source.indexOf("temporary {")).color, "rewind goes to the checkpoint before it") + assertEquals(boss, at("temporary(until").color) + assertEquals(arena, at("rewind(\"arena").color) + + fun target(text: String) = analyzer.gutterTargets("Colors.kts", source, source.indexOf(text)).single().location.offset + assertEquals(source.indexOf("checkpoint(\"boss"), target("temporary(until")) + assertEquals(source.indexOf("checkpoint(\"arena"), target("rewind(\"arena")) + } + @Test fun `attached nodes have no server run targets`() { val text = diff --git a/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt b/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt index 8c4b507c0..01fee659c 100644 --- a/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt +++ b/addons/compiler/src/test/kotlin/IdeCodeInsightTest.kt @@ -89,6 +89,20 @@ class IdeCodeInsightTest { assertEquals("someOtherFunction", items.first { it.name == "someOtherFunction" }.insert) } + @Test + fun `expression snippets fit only where an expression of their type may go`() { + val types = listOf("kotlin.String", "kotlin.Int") + fun accepted(text: String) = + analyzer.acceptedExpressionTypes("slots.kts", text.replace("|", ""), text.indexOf('|'), types) + + assertEquals(listOf("kotlin.Int"), accepted("fun takesInt(value: Int) = value\ntakesInt(|)")) + assertEquals(listOf("kotlin.String"), accepted("val text: String = po|")) + assertEquals(types, accepted("val number = 1\npo|")) + assertEquals(types, accepted("fun run(block: () -> Unit) = block()\nrun { po| }")) + assertNull(accepted("val text = \"abc\".le|")) + assertNull(accepted("val po|")) + } + @Test fun `completion preserves existing lambda bodies and callable references`() { for (usage in listOf("someOldFunction { println(42) }", "::someOldFunction")) { diff --git a/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt new file mode 100644 index 000000000..9d68bd3e9 --- /dev/null +++ b/addons/compiler/src/test/kotlin/ScriptDefinitionsLifecycleTest.kt @@ -0,0 +1,99 @@ +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import org.koin.core.Koin +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentInitializerImpl +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonDescriptor +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonHostServices +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonsChangedEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.ScriptDefaultImportsEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptDefinitionsEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironmentInitializer +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import java.io.File +import java.util.jar.JarOutputStream +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +abstract class LifecycleScript + +class ScriptDefinitionsLifecycleTest { + @Test + fun `compiler starts on load and refreshes late definitions without remapping`(@TempDir directory: File) = runBlocking { + val scope = CoroutineScope(Job()) + val addonScope = CoroutineScope(Job()) + val classpath = System.getProperty("java.class.path").split(File.pathSeparator).map(::File).filter(File::exists) + var preparations = 0 + val initializer = ScriptingEnvironmentInitializerImpl( + prepareClasspath = { preparations++; Mappings.EMPTY to classpath }, + definitions = { + val providers = mutableListOf(ScriptClassProvider("kts", LifecycleScript::class.qualifiedName!!)) + ScriptDefinitionsEvent.post(ScriptDefinitionsEvent(providers)) + providers.map { provider -> + val imports = provider.defaultImports.toMutableList() + ScriptDefaultImportsEvent.post(ScriptDefaultImportsEvent(provider.extension, imports)) + provider.copy(defaultImports = imports.distinct()) + } + }, + ) + val compilerJar = directory.resolve("compiler.jar").also { JarOutputStream(it.outputStream()).close() } + val services = TestHostServices() + val context = HollowAddonContext( + services, HollowAddonDescriptor("compiler", "test", null), compilerJar, javaClass.classLoader, Koin(), + ) + try { + ScriptingEnvironment.clear() + initializer.load(context, scope) + HollowAddonsChangedEvent.subscribe(scope) { initializer.onAddonsChanged(it) } + assertEquals(1, preparations) + val initial = ScriptingEnvironment.INSTANCE + assertSame(initializer, services.findService(ScriptingEnvironmentInitializer::class)) + + ScriptDefaultImportsEvent.subscribe(addonScope) { it.defaultImports += "java.time.Instant" } + ScriptDefinitionsEvent.subscribe(addonScope) { + it.providers += ScriptClassProvider("addon.kts", LifecycleScript::class.qualifiedName!!) + } + HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) + val first = ScriptingEnvironment.INSTANCE + assertNotSame(initial, first) + assertTrue(first.compiler.compile("first.addon.kts", "val value = Instant.EPOCH").isSuccess) + assertTrue(first.analyzer.diagnostic("first.addon.kts", "val value = Instant.EPOCH").isEmpty()) + assertEquals(1, preparations) + + HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) + assertSame(first, ScriptingEnvironment.INSTANCE) + + addonScope.cancel() + HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) + val refreshed = ScriptingEnvironment.INSTANCE + assertNotSame(first, refreshed) + assertEquals(1, preparations) + assertTrue(refreshed.compiler.compile("second.kts", "val value = Instant.EPOCH").isFailure) + assertTrue(refreshed.analyzer.diagnostic("second.kts", "val value = Instant.EPOCH").isNotEmpty()) + } finally { + scope.cancel() + addonScope.cancel() + initializer.unload(context) + File("hollowengine").deleteRecursively() + } + } + + private class TestHostServices : HollowAddonHostServices { + private val services = HashMap, Any>() + override fun log(message: String) = Unit + override fun publishService(type: KClass, instance: T) { services[type] = instance } + override fun unpublishService(type: KClass, instance: T) { services.remove(type, instance) } + @Suppress("UNCHECKED_CAST") + override fun findService(type: KClass): T? = services[type] as T? + override fun findServices(type: KClass): List = listOfNotNull(findService(type)) + } +} diff --git a/addons/compiler/src/test/kotlin/ScriptRenameTest.kt b/addons/compiler/src/test/kotlin/ScriptRenameTest.kt new file mode 100644 index 000000000..17588d945 --- /dev/null +++ b/addons/compiler/src/test/kotlin/ScriptRenameTest.kt @@ -0,0 +1,106 @@ +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +abstract class RenameScript + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ScriptRenameTest { + private val fixture = AnalysisEnvironmentTestFixture { + ScriptingEnvironmentImpl( + javaHome = File(System.getProperty("java.home")), + classpath = System.getProperty("java.class.path").split(File.pathSeparator).filter(String::isNotBlank) + .map(::File).filter(File::exists).distinctBy { it.absoluteFile.normalize() }, + scriptTypes = listOf( + ScriptClassProvider( + extension = ".rename.kts", + baseClass = RenameScript::class.qualifiedName!! + ) + ), + mappings = Mappings.EMPTY, + ) + } + + @BeforeAll + fun startEnvironment() = fixture.start() + + @AfterEach + fun resetEnvironment() = fixture.reset() + + @AfterAll + fun closeEnvironment() = fixture.close() + + private val analyzer get() = fixture.environment.analyzer + + @Test + fun `a declaration is renamed in every script that imports it, and nowhere its name means something else`() { + script("core.rename.kts", "class Quest(val title: String)\nfun greet(quest: Quest) = quest.title") + script("other.rename.kts", "fun greet(times: Int) = times\nval twice = greet(2)") + val main = "@file:Import(\"core.rename.kts\")\n\nval first = greet(Quest(\"start\"))\n" + val caret = main.indexOf("greet") + + val renames = analyzer.rename("scripts/main.rename.kts", main, caret, "welcome", emptyMap()) + .associate { it.path to it.applied() } + + assertEquals( + mapOf( + "scripts/main.rename.kts" to "@file:Import(\"core.rename.kts\")\n\nval first = welcome(Quest(\"start\"))\n", + "scripts/core.rename.kts" to "class Quest(val title: String)\nfun welcome(quest: Quest) = quest.title", + ), + renames, + ) + } + + @Test + fun `an open document is renamed as it is typed, not as it was saved`() { + script("model.rename.kts", "class Hero(val name: String)") + script("user.rename.kts", "@file:Import(\"model.rename.kts\")\n") + val open = "@file:Import(\"model.rename.kts\")\nval hero = Hero(\"Steve\")\n" + val main = "@file:Import(\"model.rename.kts\")\nval other = Hero(\"Alex\")\n" + + val renames = analyzer.rename( + "scripts/main.rename.kts", main, main.indexOf("Hero"), "Champion", + mapOf("scripts/user.rename.kts" to open), + ).associate { it.path to it.applied() } + + assertEquals( + "@file:Import(\"model.rename.kts\")\nval hero = Champion(\"Steve\")\n", + renames["scripts/user.rename.kts"] + ) + assertEquals("class Champion(val name: String)", renames["scripts/model.rename.kts"]) + } + + @Test + fun `a local stays in its own body`() { + script("elsewhere.rename.kts", "val count = 3") + val main = "fun run(): Int {\n val count = 1\n return count + 1\n}\nfun walk() { val count = 2 }\n" + + val renames = analyzer.rename("scripts/main.rename.kts", main, main.indexOf("count"), "total", emptyMap()) + + assertEquals( + listOf("fun run(): Int {\n val total = 1\n return total + 1\n}\nfun walk() { val count = 2 }\n"), + renames.map { it.applied() }, + ) + } + + @Test + fun `a name from a library cannot be renamed`() { + val main = "val size = listOf(1).size\n" + assertNull(analyzer.renameTarget("scripts/main.rename.kts", main, main.indexOf("listOf"))) + } + + private fun script(path: String, text: String) { + File("hollowengine/scripts", path).apply { + parentFile.mkdirs() + writeText(text) + } + } +} diff --git a/addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt b/addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt new file mode 100644 index 000000000..a6b3135af --- /dev/null +++ b/addons/compiler/src/test/kotlin/SharedScriptIdentityTest.kt @@ -0,0 +1,159 @@ +import org.junit.jupiter.api.io.TempDir +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.annotations.Import +import ru.hollowhorizon.hollowengine.common.scripting.annotations.SharedScript +import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache +import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses +import ru.hollowhorizon.hollowengine.common.scripting.compiling.loadKotlinCompiledScriptFromJar +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import ru.hollowhorizon.hollowengine.common.scripting.source.DirectoryScriptSource +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +abstract class IdentityStartup + +class SharedScriptIdentityTest { + @Test + fun `packet classes have one identity through nested relative imports and startup roots`(@TempDir directory: File) { + directory.resolve("datas").mkdirs() + directory.resolve("packets").mkdirs() + directory.resolve("datas/ArrowData.kts").writeText( + "@file:SharedScript\nimport kotlinx.serialization.Serializable\n@Serializable data class ArrowData(val value: Boolean)", + ) + directory.resolve("packets/PlayerArrowsPacket.kts").writeText( + """ + @file:SharedScript + @file:Import("../datas/ArrowData.kts") + import kotlinx.serialization.Serializable + @Serializable + class PlayerArrowsPacket(val list: List) + """.trimIndent(), + ) + directory.resolve("fnf.startup.kts").writeText( + """ + @file:Import("./packets/PlayerArrowsPacket.kts") + val packet = PlayerArrowsPacket(listOf(ArrowData(true))) + """.trimIndent(), + ) + directory.resolve("fnf.reader.kts").writeText( + """ + @file:Import("./packets/PlayerArrowsPacket.kts", "./fnf.startup.kts") + import kotlinx.serialization.json.Json + import kotlinx.serialization.encodeToString + import kotlinx.serialization.decodeFromString + val packetClass = PlayerArrowsPacket::class.java + val dataClass = ArrowData::class.java + val startupPacket = packet + val roundTrip = Json.decodeFromString(Json.encodeToString(packet)) + """.trimIndent(), + ) + withScripts(directory, "packet-identity") { _, namespace -> + val startupId = ScriptId(namespace, "fnf.startup.kts") + val readerId = ScriptId(namespace, "fnf.reader.kts") + val startup = ScriptLoader.execute(startupId).getOrThrow() + val packet = startup.javaClass.getMethod("getPacket").invoke(startup) + val reader = ScriptLoader.execute(readerId).getOrThrow() + assertSame(packet.javaClass, reader.javaClass.getMethod("getPacketClass").invoke(reader)) + assertSame(packet, reader.javaClass.getMethod("getStartupPacket").invoke(reader)) + val roundTrip = reader.javaClass.getMethod("getRoundTrip").invoke(reader) + assertSame(packet.javaClass, roundTrip.javaClass) + val data = (packet.javaClass.getMethod("getList").invoke(packet) as List<*>).single()!! + assertSame(data.javaClass, reader.javaClass.getMethod("getDataClass").invoke(reader)) + val decodedData = (roundTrip.javaClass.getMethod("getList").invoke(roundTrip) as List<*>).single()!! + assertSame(data.javaClass, decodedData.javaClass) + SharedScriptClasses.keepInstance(startup::class) + SharedScriptClasses.clearInstances() + repeat(2) { + val cached = ScriptCache.artifact(readerId).loadKotlinCompiledScriptFromJar(javaClass.classLoader) + .execute().getOrThrow() + assertSame(packet.javaClass, cached.javaClass.getMethod("getPacketClass").invoke(cached)) + assertSame(packet, cached.javaClass.getMethod("getStartupPacket").invoke(cached)) + } + val rerun = ScriptCache.artifact(startupId).loadKotlinCompiledScriptFromJar(javaClass.classLoader) + .execute().getOrThrow() + assertSame(startup, rerun) + + directory.resolve("datas/ArrowData.kts").appendText("\nval revision = 2") + val failure = assertFailsWith { + ScriptLoader.execute(readerId).getOrThrow() + } + assertTrue(failure.message.orEmpty().contains("Restart the game"), failure.message) + } + } + + @Test + fun `shared dependency policy rejects private imports in compiler and IDE`(@TempDir directory: File) { + directory.resolve("data.kts").writeText("class PrivateData") + directory.resolve("packet.kts").writeText( + "@file:SharedScript\n@file:Import(\"data.kts\")\nclass Packet(val data: PrivateData)", + ) + directory.resolve("main.kts").writeText("@file:Import(\"packet.kts\")\nval packet = Packet(PrivateData())") + withScripts(directory, "private-shared-import") { environment, namespace -> + val failure = ScriptLoader.compile(ScriptId(namespace, "main.kts")).exceptionOrNull() + assertTrue(failure?.message.orEmpty().contains("imports non-shared script"), failure?.message) + assertTrue(failure?.message.orEmpty().contains("data.kts"), failure?.message) + val diagnostics = environment.analyzer.diagnostic( + "$namespace:main.kts", directory.resolve("main.kts").readText(), + ) + assertTrue(diagnostics.any { it.message.contains("imports non-shared script") }, diagnostics.toString()) + + val startupText = "@file:Import(\"data.kts\")\nval data = PrivateData()" + directory.resolve("bad.startup.kts").writeText(startupText) + assertTrue(ScriptLoader.compile(ScriptId(namespace, "bad.startup.kts")).isFailure) + assertTrue(environment.analyzer.diagnostic("$namespace:bad.startup.kts", startupText) + .any { it.message.contains("imports non-shared script") }) + } + } + + @Test + fun `loading a compiled shared script does not reidentify it after a source edit`(@TempDir directory: File) { + directory.resolve("model.kts").writeText("@file:SharedScript\nclass Model") + directory.resolve("first.kts").writeText("@file:Import(\"model.kts\")\nval model = Model()") + directory.resolve("second.kts").writeText("@file:Import(\"model.kts\")\nval model = Model()") + withScripts(directory, "frozen-shared-identity") { _, namespace -> + val first = ScriptLoader.compile(ScriptId(namespace, "first.kts")).getOrThrow() + val second = ScriptLoader.compile(ScriptId(namespace, "second.kts")).getOrThrow() + directory.resolve("model.kts").appendText("\nval revision = 2") + val firstInstance = first.execute().getOrThrow() + val secondInstance = second.execute().getOrThrow() + val firstModel = firstInstance.javaClass.getMethod("getModel").invoke(firstInstance) + val secondModel = secondInstance.javaClass.getMethod("getModel").invoke(secondInstance) + assertSame(firstModel.javaClass, secondModel.javaClass) + } + } + + private fun withScripts( + directory: File, + namespace: String, + body: (ScriptingEnvironmentImpl, String) -> Unit, + ) { + val imports = listOf(Import::class.qualifiedName!!, SharedScript::class.qualifiedName!!) + val classpath = System.getProperty("java.class.path").split(File.pathSeparator).map(::File).filter(File::exists) + val environment = ScriptingEnvironmentImpl( + javaHome = File(System.getProperty("java.home")), + classpath = classpath, + scriptTypes = listOf( + ScriptClassProvider("kts", "kotlin.Any", imports), + ScriptClassProvider("startup.kts", IdentityStartup::class.qualifiedName!!, imports, shared = true), + ), + mappings = Mappings.EMPTY, + ) + ScriptRegistry.register(DirectoryScriptSource(namespace, directory, javaClass.classLoader, fingerprint = "test")) + ScriptingEnvironment.INSTANCE = environment + try { + body(environment, namespace) + } finally { + ScriptRegistry.unregister(namespace) + ScriptingEnvironment.clear() + File("hollowengine").deleteRecursively() + } + } +} diff --git a/addons/compiler/src/test/kotlin/StageAnalysisTest.kt b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt new file mode 100644 index 000000000..cfdb8a3b6 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageAnalysisTest.kt @@ -0,0 +1,311 @@ +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionSink +import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Narrative constraints must be visible before code generation in the built-in IDE. */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StageAnalysisTest { + private lateinit var compiler: StageCompiler + + @BeforeAll + fun setUp() { + compiler = StageCompiler() + } + + @AfterAll + fun close() = compiler.close() + private fun diagnostics(source: String): List = + compiler.environment.analyzer.diagnostic("editing.stage.kts", source.trimIndent()) + + @Test + fun `the editor locates an enclosing local helper at its call`() { + val source = """ + @State + suspend fun main() { + var lock: Any? = null + suspend fun playSing(name: String) { lock = name } + listOf("left", "right").forEach { direction -> + async("input_" + direction) { playSing(direction) } + } + await("end") + } + """.trimIndent() + val errors = diagnostics(source).filter { it.severity.isError() } + val error = errors.firstOrNull { it.message.contains("local function 'playSing'") } + assertTrue(error != null, errors.toString()) + assertEquals(5, error.range.start.line) + assertTrue(error.message.contains("'lock', which cannot be saved"), error.message) + } + + @Test + fun `named values tell what they turned out to be`() { + val source = """ + suspend fun first() { + val storyPin0 = (listOf("x")) + } + suspend fun second() { + val storyPin1 = (listOf("x")).iterator().next() + val storyPin2 = (missing) + val other = 1 + } + """.trimIndent() + val types = compiler.environment.analyzer.propertyTypes("types.stage.kts", source, "storyPin").types + assertEquals( + mapOf("storyPin0" to "kotlin.collections.List", "storyPin1" to "kotlin.String"), + types + ) + } + + @Test + fun `values fit places the way Kotlin assigns them`() { + val source = """ + suspend fun check() { + val storyPin0 = listOf("x") + val storyPin1 = listOf(1) + val storyPin2 = listOf() + val storyPin3: String? = null + val storyPlace0: List = listOf() + val storyPlace1: Iterable<*> = listOf() + val storyPlace2: String = "" + val storyPlace3: Missing = "" + } + """.trimIndent() + val found = compiler.environment.analyzer.propertyTypes("fits.stage.kts", source, "storyPin", "storyPlace") + assertEquals(setOf("storyPlace0", "storyPlace1"), found.fits["storyPin0"]) + assertEquals(setOf("storyPlace1"), found.fits["storyPin1"]) + assertEquals(setOf("storyPlace1"), found.fits["storyPin2"]) + assertEquals(setOf("storyPlace2"), found.fits["storyPin3"]) + assertTrue("storyPlace3" !in found.types) + } + + @Test + fun `pins of a graph get their types from the script it compiles to`() { + val graph = StoryGraph( + variables = listOf(StoryVariable("trust", "0")), + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Expression("text", "\"a\""), + StoryNode.Expression("sum", "trust + 1"), + StoryNode.Pure("list", "list", count = 1), + StoryNode.Loop("each", ""), + StoryNode.Code("block", "val found = 2\nfound.toString()"), + ), + links = listOf( + StoryLink("s", StoryPins.NEXT, "each", StoryPins.IN), + StoryLink("text", StoryPins.VALUE, "list", "a"), + StoryLink("list", "list", "each", StoryPins.ITEMS), + ), + ) + val pins = StoryGraphCompiler.pins(graph, StoryFunctions.Builtin).entries.toList() + val script = StoryGraphCompiler.compile(graph).code + pins.withIndex().joinToString("") { (index, pin) -> + "\nsuspend fun storyPinOf$index() {\nval storyPin$index = (\n${pin.value}\n)\n}" + } + val found = compiler.environment.analyzer.propertyTypes("graph.stage.kts", script, "storyPin").types + val types = found.mapKeys { (name, _) -> pins[name.removePrefix("storyPin").toInt()].key } + assertEquals("kotlin.String", types["text" to StoryPins.VALUE]) + assertEquals("kotlin.Int", types["sum" to StoryPins.VALUE]) + assertEquals("kotlin.collections.List", types["list" to "list"]) + assertEquals("kotlin.String", types["each" to StoryPins.ITEM]) + assertEquals("kotlin.String", types["block" to StoryPins.VALUE]) + } + + @Test + fun `the editor reports mutable and unserializable captures`() { + val errors = diagnostics( + """ + fun start(runs: StoryRuns) { + var counter = 0 + val lock = Any() + runs.launch("main") { log(counter.toString()); log(lock.toString()) } + } + """ + ) + assertTrue( + errors.any { it.message.contains("can share 'var counter' only with the @State function or lambda that declares it directly") }, + errors.toString() + ) + assertTrue(errors.any { it.message.contains("'lock' cannot be saved") }, errors.toString()) + } + + @Test + fun `the editor validates stage declarations and argument types`() { + val errors = diagnostics( + """ + @State fun plain() {} + @State suspend fun generic(value: T) {} + @State suspend fun unsafe(lock: Any): Any = lock + fun outer() { @State suspend fun local() {} } + """ + ) + listOf( + "must be a suspend function", + "cannot have type parameters", + "'lock' cannot be", + "The result of a stage is saved", + "top level" + ).forEach { expected -> + assertTrue(errors.any { expected in it.message }, "$expected: $errors") + } + } + + @Test + fun `safe helpers and captures do not receive narrative errors`() { + val errors = diagnostics( + """ + import ru.hollowhorizon.hollowengine.addons.narrative.story.State as Stage + + fun greet(name: String) = log(name) + @Stage suspend fun main() { + val name = "left" + async("input") { + fun sing() = greet(name) + sing() + await("end") + } + await("end") + } + """ + ).filter { it.severity.isError() } + assertTrue(errors.isEmpty(), errors.toString()) + } + + @Test + fun `the editor reports stage calls in lambdas and exception handlers`() { + val errors = diagnostics( + """ + @State suspend fun step() {} + @State suspend fun main() { + listOf(1).forEach { step() } + try { log("try") } catch (failure: Exception) { step() } finally { step() } + } + """ + ) + assertTrue(errors.any { "cannot be called from a lambda" in it.message }, errors.toString()) + assertEquals(2, errors.count { "cannot be called in catch or finally" in it.message }, errors.toString()) + } + + @Test + fun `the editor validates checkpoint placement and names`() { + val errors = diagnostics( + """ + suspend fun helper() { checkpoint("outside") } + @State suspend fun main() { + checkpoint("gate") + checkpoint("gate") + checkpoint(StageTrace.token) + } + """ + ) + listOf("must be called directly", "appears twice", "needs a constant name").forEach { expected -> + assertTrue(errors.any { expected in it.message }, "$expected: $errors") + } + } + + @Test + fun `the editor validates actions and duplicate stage names`() { + val errors = diagnostics( + """ + @State("same") suspend fun first() {} + @State("same") suspend fun second() {} + suspend fun invalid() = action { perform { } } + @Action suspend fun unsafe(lock: Any) = action { perform { } } + @Action suspend fun missing(@Unsaved lock: Any) = action { perform { } } + """ + ) + listOf( + "Two stages", + "must be the body of an @Action", + "'lock' cannot be saved", + "needs one" + ).forEach { expected -> + assertTrue(errors.any { expected in it.message }, "$expected: $errors") + } + } + + @Test + fun `the editor rejects function references and stored ordinary lambdas`() { + val errors = diagnostics( + """ + suspend fun helper() {} + fun start(runs: StoryRuns) { + val body: @State suspend () -> Unit = {} + runs.launch("one", ::helper) + runs.launch("two", body) + } + """ + ) + assertEquals(2, errors.count { "a regular function value has no saved stage" in it.message }, errors.toString()) + } + + @Test + fun `lambda arguments and results have the same persistence restrictions as named stages`() { + val errors = diagnostics( + """ + fun consume(block: @State suspend (Any) -> Unit) {} + fun produce(block: @State suspend () -> Any) {} + fun start() { + consume { lock -> log(lock.toString()) } + produce { Any() } + } + """ + ) + assertTrue(errors.any { "'lock' cannot be" in it.message }, errors.toString()) + assertTrue(errors.any { "The result of a stage is saved" in it.message }, errors.toString()) + } + + @Test + fun `unfinished stage code produces diagnostics without breaking editor analysis`() { + val errors = diagnostics( + """ + @State suspend fun main() { + async("timer") { + val captured = + } + } + """ + ) + assertTrue(errors.any { it.severity.isError() }, errors.toString()) + } + + @Test + fun `a rewind to a checkpoint the script does not have is a warning`() { + val warnings = diagnostics( + """ + @State + suspend fun main() { + checkpoint("arena") + rewind("arnea") + } + """ + ).filter { !it.severity.isError() } + assertTrue(warnings.any { "No checkpoint of this script is named 'arnea'" in it.message }, warnings.toString()) + } + + @Test + fun `the names of checkpoints are offered inside rewind`() { + val source = """ + @State + suspend fun main() { + checkpoint("arena") + checkpoint("boss") + rewind("ar") + } + """.trimIndent() + val items = ArrayList() + compiler.environment.analyzer.completions( + "names.stage.kts", + source, + source.indexOf("\"ar\")") + 3, + CompletionSink { batch -> + batch.forEach { items += it.show } + true + }) + assertEquals(listOf("arena"), items) + } +} diff --git a/addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt b/addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt new file mode 100644 index 000000000..c87774886 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageCheckpointIsolationTest.kt @@ -0,0 +1,31 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class StageCheckpointIsolationTest : StageTest() { + @Test + fun `repeated rewinds restore the original locals of an earlier checkpoint`() = runTest { + val world = world("checkpointLocals", """ + @State suspend fun main() { + var value = 10 + checkpoint("start") + log("value " + value) + value++ + checkpoint("after") + log("after " + value) + await("end") + } + """) + var story = world.launch("main") + assertEquals(listOf("value 10", "after 11"), StageTrace.take()) + repeat(2) { + story.rewind("start") + world.settle() + assertEquals(listOf("value 10", "after 11"), StageTrace.take()) + world.restart() + story = world.runs["main"]!! + assertEquals(listOf("after 11"), StageTrace.take()) + } + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt new file mode 100644 index 000000000..ed2aeaf6b --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageCompileErrorsTest.kt @@ -0,0 +1,279 @@ +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains + +/** What a stage cannot do is a compilation error that says why, not a story that breaks after a restart. */ +class StageCompileErrorsTest { + private lateinit var compiler: StageCompiler + + @BeforeTest + fun setUp() { + compiler = StageCompiler() + } + + @AfterTest + fun tearDown() = compiler.close() + + private fun assertError(expected: String, code: String) = assertContains(compiler.errors(code), expected) + + @Test + fun `a stage cannot be called from a lambda inside a stage`() = assertError( + "cannot be called from a lambda", """ + @State suspend fun step() {} + + @State + suspend fun main() { + listOf(1, 2).forEach { step() } + } + """ + ) + + @Test + fun `a stage cannot be called in catch or finally`() { + assertError( + "cannot be called in catch or finally", """ + @State suspend fun step() {} + + @State + suspend fun main() { + try { log("try") } catch (failure: Exception) { step() } + } + """ + ) + assertError( + "cannot be called in catch or finally", """ + @State suspend fun step() {} + + @State + suspend fun main() { + try { log("try") } finally { step() } + } + """ + ) + } + + @Test + fun `a local that cannot be saved cannot outlive a stage call`() = assertError( + "'iterator', used after the stage call 'step#1'", """ + @State suspend fun step() {} + + @State + suspend fun main() { + val iterator = listOf(1).iterator() + step() + log(iterator.next().toString()) + } + """ + ) + + @Test + fun `a value computed before a stage call in the same expression must be saveable`() = assertError( + "A value computed before the stage call 'name#1'", """ + @State suspend fun name(): String = "a" + + fun join(first: Any, second: String) = log(first.toString() + second) + + @State + suspend fun main() { + join(Any(), name()) + } + """ + ) + + @Test + fun `arguments and results of stages must be saveable`() { + assertError( + "'target' cannot be", """ + @State suspend fun aim(target: Any) {} + """ + ) + assertError( + "The result of a stage is saved", """ + @State suspend fun make(): Any = Any() + """ + ) + assertError( + "cannot have type parameters", """ + @State suspend fun pass(value: T) {} + """ + ) + assertError( + "'this' cannot be", """ + @State suspend fun Any.shout() {} + """ + ) + } + + @Test + fun `a stage must be a top level suspend function of a script`() { + assertError( + "must be a suspend function", """ + @State fun plain() {} + """ + ) + assertError( + "must be declared at the top level of a script", """ + class Holder { + @State suspend fun inner() {} + } + """ + ) + } + + @Test + fun `two stages cannot share a name`() = assertError( + "Two stages of this script are named 'same'", """ + @State(name = "same") suspend fun one() {} + + @State(name = "same") suspend fun two() {} + """ + ) + + @Test + fun `checkpoints need a constant unique name inside a stage`() { + assertError( + "must be called directly in a @State function", """ + suspend fun helper() = checkpoint("outside") + """ + ) + assertError( + "needs a constant name", """ + @State + suspend fun main() { + val name = "dynamic" + log("x") + checkpoint(name) + } + """ + ) + assertError( + "appears twice", """ + @State + suspend fun main() { + checkpoint("gate") + checkpoint("gate") + } + """ + ) + } + + @Test + fun `a stage lambda saves what it captures so it can capture only values that can be saved`() { + assertError( + "can share 'var counter' only with the @State function or lambda that declares it directly", """ + fun start(runs: StoryRuns) { + var counter = 1 + counter++ + runs.launch("story") { log(counter.toString()) } + } + """ + ) + assertError( + "'lock' cannot be saved", """ + fun start(runs: StoryRuns) { + val lock = Any() + runs.launch("story") { log(lock.toString()) } + } + """ + ) + } + + @Test + fun `a loop that calls stages cannot go over a sequence`() = assertError( + "it may never end", """ + @State suspend fun step(value: Int) {} + + @State + suspend fun main() { + for (value in generateSequence(1) { it + 1 }) step(value) + } + """ + ) + + @Test + fun `a stage lambda takes as many parameters as there are lambda classes for`() = assertError( + "at most 8 parameters", """ + fun many(block: @State suspend (Int, Int, Int, Int, Int, Int, Int, Int, Int) -> Unit) = Unit + + fun start() = many { a, b, c, d, e, f, g, h, i -> log("${'$'}a") } + """ + ) + + @Test + fun `a loop that calls stages cannot go over values a story cannot keep`() = assertError( + "goes over a copy of its elements", """ + @State suspend fun step() {} + + @State + suspend fun main() { + for (lock in listOf(Any(), Any())) step() + } + """ + ) + + @Test + fun `action is only written as the body of an action`() = assertError( + "must be the body of an @Action function", """ + suspend fun helper() = action { perform { } } + """ + ) + + @Test + fun `the arguments of an action must be serializable`() = assertError( + "'lock' cannot be saved", """ + @Action + suspend fun hold(lock: Any) = action { perform { } } + """ + ) + + @Test + fun `an unsaved argument of an action needs a default`() = assertError( + "needs one", """ + @Action + suspend fun hold(@Unsaved lock: Any) = action { perform { } } + """ + ) + + @Test + fun `the result of an action a stage keeps must be saveable`() = assertError( + "The result of the action call 'lock#1'", """ + @Action + suspend fun lock(): Any = action { perform { Any() } } + + @State + suspend fun main() { + val held = lock() + log(held.toString()) + } + """ + ) + + @Test + fun `an action of an addon is a boundary like an action of the script`() = assertError( + "'iterator', used after the action call 'move#1'", """ + import net.minecraft.world.phys.Vec3 + import ru.hollowhorizon.hollowengine.addons.narrative.npcs.move + import ru.hollowhorizon.hollowengine.common.entities.NpcEntity + + @State + suspend fun main(guard: NpcEntity) { + val iterator = listOf(1).iterator() + guard.move(Vec3.ZERO) + log(iterator.next().toString()) + } + """ + ) + + @Test + fun `a checkpoint cannot share its name with a function of the script`() = assertError( + "A checkpoint cannot be named 'arena'", """ + @State suspend fun arena() {} + + @State + suspend fun main() { + checkpoint("arena") + arena() + } + """ + ) +} diff --git a/addons/compiler/src/test/kotlin/StageConcurrencyTest.kt b/addons/compiler/src/test/kotlin/StageConcurrencyTest.kt new file mode 100644 index 000000000..2a034726d --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageConcurrencyTest.kt @@ -0,0 +1,172 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Branch cancellation and composition must preserve the lifetime and identity of each run. */ +class StageConcurrencyTest : StageTest() { + @Test + fun `a cancelled branch can be replaced under the same name before cancellation finishes`() = runTest { + val world = world("replaceBranch", """ + @State + suspend fun main() { + val first = async("timer") { + try { await("never") } finally { log("cancelled") } + } + await("replace") + first.cancel() + val second = async("timer") { log("replacement"); await("timer") } + second.join() + log("done") + } + """) + world.launch("main") + world.release("replace") + assertEquals(listOf("cancelled", "replacement"), StageTrace.take()) + world.release("timer") + assertEquals(listOf("done"), StageTrace.take()) + world.stop() + } + + @Test + fun `any waits for the requested number and only then cancels the remaining branches`() = runTest { + val world = world("anyBranches", """ + @State + suspend fun main() { + val a = async("a") { await("a"); log("a") } + val b = async("b") { await("b"); log("b") } + val c = async("c") { + try { await("c"); log("c") } finally { log("close c") } + } + val ended = any(2, a, b, c) + log("winners " + ended.joinToString { it.key }) + await("end") + } + """) + world.launch("main") + world.release("a") + assertEquals(listOf("a"), StageTrace.take()) + world.release("b") + assertEquals(listOf("b", "winners main/a, main/b", "close c"), StageTrace.take()) + world.stop() + } + + @Test + fun `any counts branches that have already ended`() = runTest { + val world = world("alreadyEnded", """ + @State + suspend fun main() { + val a = async("a") { log("a") } + val b = async("b") { log("b") } + a.join() + b.join() + log("ended " + any(2, a, b).size) + } + """) + world.launch("main") + assertEquals(listOf("a", "b", "ended 2"), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty()) + world.stop() + } + + @Test + fun `race rejects an empty set instead of suspending forever`() = runTest { + val world = world("emptyRace", """ + @State + suspend fun main() { + try { race() } catch (failure: IllegalArgumentException) { log("empty") } + } + """) + world.launch("main") + assertEquals(listOf("empty"), StageTrace.take()) + world.stop() + } + + @Test + fun `a saved stage lambda keeps its result type when used to start a branch`() = runTest { + val world = world("savedLambdaResult", """ + @State + suspend fun consume(body: @State suspend () -> Int) { + checkpoint("ready") + await("go") + val branch = async("value", body) + log("value " + branch.await()) + await("end") + } + + @State suspend fun main() { consume { 42 } } + """) + world.launch("main") + world.restart() + world.release("go") + assertEquals(listOf("value 42"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch name cannot be taken from another active stage`() = runTest { + val world = world("branchOwnership", """ + @State suspend fun inner() { + try { async("ambient") { await("never") } } + catch (failure: StoryException) { log("conflict " + failure.message) } + } + @State suspend fun main() { + async("ambient") { await("never") } + inner() + await("end") + } + """) + world.launch("main") + assertTrue(StageTrace.take().single().contains("belongs to another active stage")) + world.stop() + } + + @Test + fun `cancelling a completed timer allows the next timer to start`() = runTest { + val world = world("completedTimer", """ + @State suspend fun main() { + val first = async("timer") { log("first") } + first.join() + first.cancel() + val second = async("timer") { log("second") } + second.join() + log("done") + } + """) + world.launch("main") + assertEquals(listOf("first", "second", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch cancelled before it starts still completes join`() = runTest { + val world = world("cancelBeforeStart", """ + @State suspend fun main() { + val branch = async("timer") { log("unexpected"); await("never") } + branch.cancel() + branch.join() + log("stopped") + } + """) + world.launch("main") + assertEquals(listOf("stopped"), StageTrace.take()) + world.stop() + } + + @Test + fun `any validates distinct runs and zero cancels every supplied run`() = runTest { + val world = world("anyLimits", """ + @State suspend fun main() { + val branch = async("timer") { await("never") } + try { any(2, branch, branch) } + catch (failure: IllegalArgumentException) { log("duplicate") } + log("count " + any(0, branch).size) + branch.join() + log("active " + branch.isActive) + } + """) + world.launch("main") + assertEquals(listOf("duplicate", "count 0", "active false"), StageTrace.take()) + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageControlFlowTest.kt b/addons/compiler/src/test/kotlin/StageControlFlowTest.kt new file mode 100644 index 000000000..8d3913ac2 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageControlFlowTest.kt @@ -0,0 +1,290 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Control flow around stage calls: expressions, nested handlers, recursion, loops, failing calls. */ +class StageControlFlowTest : StageTest() { + @Test + fun `nested finally blocks run in order when a stage returns from inside them`() = runTest { + val world = world("finally", """ + @State + suspend fun step() { + log("step") + await("s") + } + + @State + suspend fun inner(): String { + try { + try { + step() + return "done" + } finally { + log("inner finally") + } + } finally { + log("outer finally") + } + } + + @State + suspend fun main() = log(inner()) + """) + world.launch("main") + assertEquals(listOf("step"), StageTrace.take()) + + world.restart() + assertEquals(listOf("inner finally", "outer finally", "step"), StageTrace.take(), "unloading runs the finally blocks too") + + world.release("s") + assertEquals(listOf("inner finally", "outer finally", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `an expression with several stage calls resumes inside each of them`() = runTest { + val world = world("arithmetic", $$""" + @State + suspend fun first(): Int { + log("first") + await("a") + return 2 + } + + @State + suspend fun second(): Int { + log("second") + await("b") + return 3 + } + + @State + suspend fun third(): Int { + log("third") + await("c") + return 4 + } + + @State + suspend fun main() { + val result = first() + second() * third() + log("result $result") + } + """ + ) + world.launch("main") + world.release("a") + assertEquals(listOf("first", "second"), StageTrace.take()) + + world.restart() + world.release("b") + world.restart() + assertEquals(listOf("second", "third", "third"), StageTrace.take(), "first and second are not called again") + + world.release("c") + assertEquals(listOf("result 14"), StageTrace.take()) + world.stop() + } + + @Test + fun `short-circuit operators call the right side only when they need it, after a restart too`() = runTest { + val world = world("operators", $$""" + @State + suspend fun check(): Boolean { + log("check") + await("check") + return StageTrace.token == "pass" + } + + @State + suspend fun validate(): Boolean { + log("validate") + await("validate") + return true + } + + @State + suspend fun load(): String? { + log("load") + await("load") + return null + } + + @State + suspend fun fallback(): String { + log("fallback") + await("fallback") + return "default" + } + + @State + suspend fun main() { + val ok = check() && validate() + log("ok $ok") + val value = load() ?: fallback() + log("value $value") + } + """ + ) + StageTrace.token = "pass" + world.launch("main") + world.release("check") + world.restart() + assertEquals(listOf("check", "validate", "validate"), StageTrace.take()) + + world.release("validate") + world.release("load") + world.restart() + assertEquals(listOf("ok true", "load", "fallback", "fallback"), StageTrace.take()) + + world.release("fallback") + assertEquals(listOf("value default"), StageTrace.take()) + + StageTrace.token = "fail" + world.launch("main", key = "second") + assertEquals(listOf("check", "ok false", "load", "fallback", "value default"), StageTrace.take()) + world.stop() + } + + @Test + fun `recursion keeps every frame with its arguments and results`() = runTest { + val world = world("recursion", $$""" + @State + suspend fun factorial(n: Int): Int { + if (n <= 1) { + await("base") + return 1 + } + val child = factorial(n - 1) + checkpoint("return") + log("return $n") + if (n == 3) await("middle") + return n * child + } + + @State + suspend fun main() = log("result ${factorial(5)}") + """ + ) + val story = world.launch("main") + assertEquals(5, story.state.split(" > ").size - 1, "main and five calls of factorial are on the stack") + + world.restart() + world.release("base") + assertEquals(listOf("return 2", "return 3"), StageTrace.take()) + + world.restart() + assertEquals(listOf("return 3"), StageTrace.take(), "the frame of n = 3 resumes after its checkpoint with child = 2") + + world.release("middle") + assertEquals(listOf("return 4", "return 5", "result 120"), StageTrace.take()) + world.stop() + } + + @Test + fun `break and continue of nested loops work before and after a restart`() = runTest { + val world = world("jumps", $$""" + @State + suspend fun visit(i: Int, j: Int) { + log("visit $i $j") + await("v$i$j") + } + + @State + suspend fun main() { + outer@ for (i in 0 until 3) { + for (j in 0 until 3) { + if (j == 1) continue + if (i == 1) continue@outer + if (i == 2 && j == 2) break@outer + visit(i, j) + } + } + log("done") + } + """ + ) + world.launch("main") + world.release("v00") + world.restart() + assertEquals(listOf("visit 0 0", "visit 0 2", "visit 0 2"), StageTrace.take()) + + world.release("v02") + world.restart() + assertEquals(listOf("visit 2 0", "visit 2 0"), StageTrace.take()) + + world.release("v20") + assertEquals(listOf("done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a do-while condition can call a stage, and continue still checks it`() = runTest { + val world = world("doWhile", $$""" + @State + suspend fun more(round: Int): Boolean { + log("more $round") + await("m$round") + return round < 2 + } + + @State + suspend fun main() { + var round = 0 + do { + round++ + if (round == 1) continue + log("round $round") + } while (more(round)) + log("done $round") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("more 1", "more 1"), StageTrace.take()) + + world.release("m1") + world.release("m2") + assertEquals(listOf("round 2", "more 2", "done 2"), StageTrace.take()) + world.stop() + } + + @Test + fun `a failed stage call leaves its caller in the segment it was in before the call`() = runTest { + val world = world("failures", """ + @State + suspend fun step(name: String) = log("step ${'$'}name") + + @State + suspend fun risky() { + log("risky") + throw IllegalArgumentException("boom") + } + + @State + suspend fun main() { + step("a") + try { + risky() + } catch (failure: IllegalArgumentException) { + log("caught") + } + log("after") + await("x") + log("end") + } + """) + world.launch("main") + assertEquals(listOf("step a", "risky", "caught", "after"), StageTrace.take()) + val frame = world.save().getCompound("main").getList("frames", 10).getCompound(0) + assertEquals("after:step#1", frame.getString("position")) + + world.restart() + assertEquals(listOf("risky", "caught", "after"), StageTrace.take(), "the segment after step repeats") + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageExecutionTest.kt b/addons/compiler/src/test/kotlin/StageExecutionTest.kt new file mode 100644 index 000000000..5662faba9 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageExecutionTest.kt @@ -0,0 +1,766 @@ +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Stages run like ordinary code, and a restart brings each frame back to the boundary it last passed: the code + * before that boundary does not run again, the segment after it does. + */ +class StageExecutionTest { + private lateinit var compiler: StageCompiler + + @BeforeTest + fun setUp() { + StageTrace.reset() + compiler = StageCompiler() + } + + @AfterTest + fun tearDown() = compiler.close() + + private fun TestScope.world(name: String, code: String) = StageWorld(this, compiler.compile(name, code)) + + @Test + fun `a nested stage resumes inside the call and the caller continues after it`() = runTest { + val world = world("nested", """ + @State + suspend fun main() { + log("main:start") + first() + log("main:after") + await("x") + log("main:end") + } + + @State + suspend fun first() { + log("first:start") + await("y") + log("first:end") + } + """) + world.launch("main") + assertEquals(listOf("main:start", "first:start"), StageTrace.take()) + + world.restart() + assertEquals(listOf("first:start"), StageTrace.take(), "first restarts from its entry, main does not run again") + + world.release("y") + assertEquals(listOf("first:end", "main:after"), StageTrace.take()) + + world.restart() + assertEquals(listOf("main:after"), StageTrace.take(), "main repeats only the segment after first") + + world.release("x") + assertEquals(listOf("main:end"), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty(), "a finished story leaves its owner") + world.stop() + } + + @Test + fun `the result of a finished stage call is restored instead of calling again`() = runTest { + val world = world("result", $$""" + @State + suspend fun main() { + val value = compute() + log("got $value") + await("x") + log("end $value") + } + + @State + suspend fun compute(): Int { + log("compute") + await("c") + return 42 + } + """ + ) + world.launch("main") + world.release("c") + assertEquals(listOf("compute", "got 42"), StageTrace.take()) + + world.restart() + assertEquals(listOf("got 42"), StageTrace.take()) + world.release("x") + assertEquals(listOf("end 42"), StageTrace.take()) + world.stop() + } + + @Test + fun `locals that outlive a stage call keep the value they had at it`() = runTest { + val world = world("locals", $$""" + fun prefix(): String { + log("prefix") + return "there" + } + + @State + suspend fun step() { + log("step") + await("s") + } + + @State + suspend fun main() { + val greeting = "hi " + prefix() + var count = 1 + step() + count += 1 + log("$greeting $count") + await("x") + step() + log("$greeting $count") + } + """ + ) + world.launch("main") + assertEquals(listOf("prefix", "step"), StageTrace.take()) + + world.restart() + assertEquals(listOf("step"), StageTrace.take(), "the locals come from the save, prefix() is not called again") + + world.release("s") + assertEquals(listOf("hi there 2"), StageTrace.take()) + + world.restart() + assertEquals(listOf("hi there 2"), StageTrace.take(), "count is restored as it was at the call, then incremented again") + + world.release("x") + assertEquals(listOf("step", "hi there 2"), StageTrace.take()) + world.stop() + } + + @Test + fun `what an expression computed before a stage call in it is not computed again`() = runTest { + val world = world("temporaries", """ + fun label(text: String): String { + log("label ${'$'}text") + return text + } + + @State + suspend fun value(): String { + await("v") + return "b" + } + + @State + suspend fun main() { + log(label("a") + value() + label("c")) + } + """) + world.launch("main") + assertEquals(listOf("label a"), StageTrace.take()) + + world.restart() + assertEquals(emptyList(), StageTrace.take()) + + world.release("v") + assertEquals(listOf("label c", "abc"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch taken before a restart is taken again without evaluating its condition`() = runTest { + val world = world("branches", """ + fun decide(): Boolean { + log("decide") + return StageTrace.token == "left" + } + + fun pick(): Int { + log("pick") + return 2 + } + + @State + suspend fun left() { + log("left") + await("l") + } + + @State + suspend fun right() = log("right") + + @State + suspend fun number(value: Int): Int { + log("number ${'$'}value") + await("n${'$'}value") + return value * 10 + } + + @State + suspend fun main() { + if (decide()) left() else right() + val result = when (pick()) { + 1 -> number(1) + else -> number(2) + } + log("result ${'$'}result") + } + """) + StageTrace.token = "left" + world.launch("main") + assertEquals(listOf("decide", "left"), StageTrace.take()) + + StageTrace.token = "right" + world.restart() + assertEquals(listOf("left"), StageTrace.take(), "the condition is not evaluated again, so the token does not matter") + + world.release("l") + assertEquals(listOf("pick", "number 2"), StageTrace.take()) + + world.restart() + assertEquals(listOf("number 2"), StageTrace.take()) + + world.release("n2") + assertEquals(listOf("result 20"), StageTrace.take()) + world.stop() + } + + @Test + fun `a loop resumes in the iteration it was in`() = runTest { + val world = world("loops", """ + fun more(i: Int): Boolean { + log("check ${'$'}i") + return i < 3 + } + + @State + suspend fun tick(i: Int) { + log("tick ${'$'}i") + await("t${'$'}i") + } + + @State + suspend fun main() { + var i = 0 + while (more(i)) { + tick(i) + i++ + } + log("done ${'$'}i") + } + """) + world.launch("main") + world.release("t0") + assertEquals(listOf("check 0", "tick 0", "check 1", "tick 1"), StageTrace.take()) + + world.restart() + assertEquals(listOf("tick 1"), StageTrace.take(), "the loop is entered without checking its condition") + + world.release("t1") + world.release("t2") + assertEquals(listOf("check 2", "tick 2", "check 3", "done 3"), StageTrace.take()) + world.stop() + } + + @Test + fun `for loops over lists and ranges resume at the element they were on`() = runTest { + val world = world("for", """ + @State + suspend fun visit(name: String) { + log("visit ${'$'}name") + await(name) + } + + @State + suspend fun count(n: Int) { + log("count ${'$'}n") + await("n${'$'}n") + } + + @State + suspend fun main() { + for (name in listOf("a", "b", "c")) visit(name) + for (n in 6 downTo 1 step 2) count(n) + log("done") + } + """) + world.launch("main") + world.release("a") + assertEquals(listOf("visit a", "visit b"), StageTrace.take()) + + world.restart() + assertEquals(listOf("visit b"), StageTrace.take()) + + world.release("b") + world.release("c") + assertEquals(listOf("visit c", "count 6"), StageTrace.take()) + + world.release("n6") + world.restart() + assertEquals(listOf("count 4", "count 4"), StageTrace.take()) + + world.release("n4") + world.release("n2") + assertEquals(listOf("count 2", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a stage resumed inside try is still guarded by its catch and finally`() = runTest { + val world = world("guarded", """ + @State + suspend fun risky() { + log("risky") + await("r") + throw IllegalArgumentException("boom") + } + + @State + suspend fun main() { + try { + risky() + log("not reached") + } catch (failure: IllegalArgumentException) { + log("caught ${'$'}{failure.message}") + } finally { + log("finally") + } + log("after") + } + """) + world.launch("main") + assertEquals(listOf("risky"), StageTrace.take()) + + world.restart() + assertEquals(listOf("finally", "risky"), StageTrace.take(), "finally runs when the story is unloaded, then the stage resumes") + + world.release("r") + assertEquals(listOf("caught boom", "finally", "after"), StageTrace.take()) + world.stop() + } + + @Test + fun `a checkpoint ends a segment and segment data survives a restart`() = runTest { + val world = world("checkpoints", """ + @State + suspend fun main() { + log("intro") + checkpoint("gate") + log("segment") + once("reward") { log("reward") } + val boss = remember("boss") { + log("spawn") + "ogre" + } + log("boss ${'$'}boss") + await("x") + log("end") + } + """) + world.launch("main") + assertEquals(listOf("intro", "segment", "reward", "spawn", "boss ogre"), StageTrace.take()) + + world.restart() + assertEquals(listOf("segment", "boss ogre"), StageTrace.take()) + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + world.stop() + } + + @Test + fun `tail calls replace the frame so a story can go back and forth forever`() = runTest { + val world = world("tails", """ + @State + suspend fun main() = ping(0) + + @State + suspend fun ping(n: Int) { + log("ping ${'$'}n") + await("p${'$'}n") + if (n < 3) pong(n + 1) else log("stop") + } + + @State + suspend fun pong(n: Int) { + log("pong ${'$'}n") + await("q${'$'}n") + ping(n + 1) + } + """) + val story = world.launch("main") + assertEquals(listOf("ping 0"), StageTrace.take()) + assertEquals("ping", story.state) + + world.release("p0") + assertEquals("pong", story.state, "pong took the place of ping") + + world.restart() + assertEquals(listOf("pong 1", "pong 1"), StageTrace.take()) + val resumed = world.runs["main"]!! + assertEquals("pong", resumed.state) + + world.release("q1") + world.release("p2") + world.release("q3") + world.release("p4") + assertEquals(listOf("ping 2", "pong 3", "ping 4", "stop"), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty()) + world.stop() + } + + @Test + fun `a tail call that returns a value hands it to the caller of the replaced stage`() = runTest { + val world = world("valueTails", """ + @State + suspend fun countdown(n: Int): Int { + log("n ${'$'}n") + await("c${'$'}n") + if (n == 0) return 100 + return countdown(n - 1) + } + + @State + suspend fun main() { + val value = countdown(2) + log("value ${'$'}value") + } + """) + val story = world.launch("main") + world.release("c2") + assertEquals("main > countdown", story.state.replace(Regex("@[^ ]+"), "")) + + world.restart() + world.release("c1") + world.release("c0") + assertEquals(listOf("n 2", "n 1", "n 1", "n 0", "value 100"), StageTrace.take()) + world.stop() + } + + @Test + fun `saved variables belong to their run and survive a restart`() = runTest { + val world = world("variables", """ + var trust by saved(0) + + @State + suspend fun main() { + trust += 1 + checkpoint("counted") + log("trust ${'$'}trust") + await("x") + trust += 10 + log("trust ${'$'}trust") + } + """) + world.launch("main", key = "a") + world.launch("main", key = "b") + assertEquals(listOf("trust 1", "trust 1"), StageTrace.take()) + + world.restart() + assertEquals(listOf("trust 1", "trust 1"), StageTrace.take()) + + world.release("x") + assertEquals(listOf("trust 11", "trust 11"), StageTrace.take()) + world.stop() + } + + @Test + fun `a launched lambda is a stage that keeps what it captured`() = runTest { + val world = world("lambdas", $$""" + @State + suspend fun meet(who: String) { + log("meet $who") + await("m") + } + + fun start(runs: StoryRuns, who: String) { + val greeting = "hello" + runs.launch("greet") { + log("$greeting $who") + meet(who) + log("bye $who") + } + } + """ + ) + world.call("start", world.runs, "alex") + assertEquals(listOf("hello alex", "meet alex"), StageTrace.take()) + + world.restart() + assertEquals(listOf("meet alex"), StageTrace.take()) + + world.release("m") + assertEquals(listOf("bye alex"), StageTrace.take()) + world.stop() + } + + @Test + fun `a stage lambda passed to a stage resumes inside its call`() = runTest { + val world = world("lambdaArguments", """ + @State + suspend fun twice(body: @State suspend () -> Unit) { + body() + log("middle") + body() + } + + @State + suspend fun step() { + log("step") + await("w") + } + + @State + suspend fun main() { + twice { step() } + log("done") + } + """) + world.launch("main") + world.restart() + assertEquals(listOf("step", "step"), StageTrace.take()) + + world.release("w") + assertEquals(listOf("middle", "step", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `branches run alongside, keep their results across a restart and end with their stage`() = runTest { + val world = world("branches", $$""" + @State + suspend fun work(name: String): Int { + log("work $name") + await(name) + return name.length + } + + @State + suspend fun ambient() { + log("ambient") + await("never") + } + + @State + suspend fun scene() { + async("ambient") { ambient() } + await("scene") + } + + @State + suspend fun main() { + val a = async("a") { work("a") } + val bb = async("bb") { work("bb") } + log("sum ${a.await() + bb.await()}") + scene() + log("after scene") + await("end") + } + """ + ) + world.launch("main") + assertEquals(listOf("work a", "work bb"), StageTrace.take()) + + world.release("a") + world.restart() + assertEquals(listOf("work bb"), StageTrace.take(), "the finished branch keeps its result, the other one resumes") + + world.release("bb") + assertEquals(listOf("sum 3", "ambient"), StageTrace.take()) + assertTrue(world.save().getCompound("main").getCompound("children").contains("ambient")) + + world.release("scene") + assertEquals(listOf("after scene"), StageTrace.take()) + assertFalse(world.save().getCompound("main").getCompound("children").contains("ambient"), "the branch ended with scene") + world.stop() + } + + @Test + fun `a recompiled script resumes where its sites still exist and starts over where they do not`() = runTest { + val first = compiler.compile("versioned", """ + @State + suspend fun wait() = await("f") + + @State + suspend fun main() { + log("one") + wait() + log("two") + } + """) + val world = StageWorld(this, first) + world.launch("main") + assertEquals(listOf("one"), StageTrace.take()) + + world.restart(compiler.compile("versioned", """ + @State + suspend fun wait() = await("f") + + @State + suspend fun main() { + log("one, edited") + log("more") + wait() + log("two, edited") + } + """)) + assertEquals(emptyList(), StageTrace.take(), "the call to wait is still there, so main resumes inside it") + + world.restart(compiler.compile("versioned", """ + @State + suspend fun wait() = await("f") + + @State + suspend fun main() { + log("rewritten") + await("f2") + } + """)) + assertEquals(listOf("rewritten"), StageTrace.take(), "the call is gone, so main starts over") + world.stop() + } + + @Test + fun `a stage entered from ordinary code is resumed only with the arguments it was saved with`() = runTest { + val world = world("helpers", """ + @State + suspend fun inner(name: String) { + log("inner ${'$'}name") + checkpoint("in") + await("i") + } + + suspend fun helper() { + log("helper") + inner(StageTrace.token) + } + + @State + suspend fun main() { + helper() + log("after") + } + """) + StageTrace.token = "a" + world.launch("main") + assertEquals(listOf("helper", "inner a"), StageTrace.take()) + + world.restart() + assertEquals(listOf("helper"), StageTrace.take(), "the same call adopts the saved frame and resumes after the checkpoint") + + StageTrace.token = "b" + world.restart() + assertEquals(listOf("helper", "inner b"), StageTrace.take(), "another argument means another call, which starts over") + + world.release("i") + assertEquals(listOf("after"), StageTrace.take()) + world.stop() + } + + @Test + fun `a story waits for the script it runs in`() = runTest { + val world = world("waits", """ + @State + suspend fun main() { + log("start") + checkpoint("started") + log("resumed") + await("x") + } + """) + world.launch("main") + assertEquals(listOf("start", "resumed"), StageTrace.take()) + + world.restart(startScript = false) + assertEquals(emptyList(), StageTrace.take(), "nothing runs without the script") + + world.startScript() + assertEquals(listOf("resumed"), StageTrace.take()) + world.stop() + } + + @Test + fun `serializable classes of the script are saved with the frame`() = runTest { + val world = world("serializable", """ + @kotlinx.serialization.Serializable + data class Loot(val name: String, val count: Int) + + @State + suspend fun main() { + val loot = Loot("gold", 3) + checkpoint("looted") + log(loot.toString()) + await("x") + } + """) + world.launch("main") + world.restart() + assertEquals(listOf("Loot(name=gold, count=3)", "Loot(name=gold, count=3)"), StageTrace.take()) + world.stop() + } + + @Test + fun `launching a running key returns that run and stopping it ends it`() = runTest { + val world = world("keys", """ + fun start(runs: StoryRuns) { + runs.launch("quest") { + log("start") + await("x") + } + } + """) + world.call("start", world.runs) + world.call("start", world.runs) + assertEquals(listOf("start"), StageTrace.take()) + + world.runs.stop("quest") + world.settle() + assertTrue(world.runs.keys.isEmpty()) + world.call("start", world.runs) + assertEquals(listOf("start"), StageTrace.take()) + world.stop() + } + + @Test + fun `a saved story can be read back after being written out`() = runTest { + val world = world("stored", """ + @State + suspend fun main() { + val items = listOf("sword", "shield") + checkpoint("armed") + log(items.joinToString()) + await("x") + } + """) + world.launch("main") + val saved = world.save().getCompound("main") + val frame = saved.getList("frames", 10).getCompound(0) + assertEquals("main", frame.getString("stage")) + assertEquals("after:armed", frame.getString("position")) + assertTrue(frame.getCompound("locals").contains("items")) + world.stop() + } + + @Test + fun `a cancellation that is not the story's own fails the story instead of putting it to sleep`() = runTest { + val world = world("preempted", """ + @State + suspend fun main() { + walk() + log("after") + } + + @State + suspend fun walk() { + log("walking") + await("path") + } + """) + val handle = world.launch("main") + StageTrace.cancel("path") + world.settle() + assertEquals(listOf("walking"), StageTrace.take()) + assertFalse(handle.isActive) + assertTrue(world.runs.keys.isEmpty(), "a failed story is not saved to wake up with nothing to run") + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageFeaturesTest.kt b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt new file mode 100644 index 000000000..73f0906e0 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageFeaturesTest.kt @@ -0,0 +1,217 @@ +import kotlinx.coroutines.test.runTest +import ru.hollowhorizon.hollowengine.addons.narrative.story.StageEntry +import ru.hollowhorizon.hollowengine.addons.narrative.story.StageScript +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** What stages accept besides plain arguments: lambdas with parameters, receivers, arrays, any iterable, edited code. */ +class StageFeaturesTest : StageTest() { + @Test + fun `a stage lambda with a receiver and parameters saves them like arguments`() = runTest { + val world = world("lambdaParameters", $$""" + @State + suspend fun twice(name: String, body: @State suspend String.(Int) -> Unit) { + name.body(1) + log("between") + name.body(2) + } + + @State + suspend fun main() { + val greeting = "hi" + twice("alex") { round -> + log("$greeting $this $round") + await("r$round") + } + log("done") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("hi alex 1", "hi alex 1"), StageTrace.take()) + + world.release("r1") + world.restart() + assertEquals(listOf("between", "hi alex 2", "hi alex 2"), StageTrace.take()) + + world.release("r2") + assertEquals(listOf("done"), StageTrace.take()) + world.stop() + } + + @Test + fun `stages take receivers and context parameters as saved arguments`() = runTest { + val world = world("receivers", $$""" + @State + suspend fun String.greet(times: Int) { + log("greet $this $times") + await("g") + log("bye $this") + } + + context(prefix: String) + @State + suspend fun announce(times: Int) { + log("$prefix $times") + await("a") + "alex".greet(times) + } + + context(prefix: String) + fun start(runs: StoryRuns) { + runs.launch("story") { announce(3) } + } + """ + ) + world.call("start", "hello", world.runs) + world.restart() + assertEquals(listOf("hello 3", "hello 3"), StageTrace.take()) + + world.release("a") + world.restart() + assertEquals(listOf("greet alex 3", "greet alex 3"), StageTrace.take()) + + world.release("g") + assertEquals(listOf("bye alex"), StageTrace.take()) + world.stop() + } + + @Test + fun `arrays are saved like lists`() = runTest { + val world = world("arrays", """ + @State + suspend fun show(names: Array, counts: IntArray) { + checkpoint("shown") + log(names.joinToString() + " " + counts.sum()) + await("x") + } + + @State + suspend fun main() = show(arrayOf("a", "b"), intArrayOf(1, 2)) + """) + world.launch("main") + world.restart() + assertEquals(listOf("a, b 3", "a, b 3"), StageTrace.take()) + world.stop() + } + + @Test + fun `a loop over any collection goes over a copy taken when it starts`() = runTest { + val world = world("iterables", $$""" + @State + suspend fun visit(name: String) { + log("visit $name") + await(name) + } + + @State + suspend fun main() { + val tags = mutableSetOf("x", "y") + for (tag in tags) { + visit(tag) + tags.clear() + } + for ((key, value) in mapOf("k" to 1)) visit("$key$value") + for (letter in "q") visit(letter.toString()) + log("done") + } + """ + ) + world.launch("main") + world.release("x") + world.restart() + assertEquals(listOf("visit x", "visit y", "visit y"), StageTrace.take(), "clearing the set does not stop the loop") + + world.release("y") + world.restart() + assertEquals(listOf("visit k1", "visit k1"), StageTrace.take()) + + world.release("k1") + world.release("q") + assertEquals(listOf("visit q", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `a local whose type changed makes its stage start over`() = runTest { + fun version(reward: String) = $$""" + @State + suspend fun main() { + log("start") + val reward = $$reward + checkpoint("reward") + log("reward $reward") + await("x") + } + """ + val world = StageWorld(this, compiler.compile("migration", version("100"))) + world.launch("main") + assertEquals(listOf("start", "reward 100"), StageTrace.take()) + + world.restart(compiler.compile("migration", version("\"gold\""))) + assertEquals(listOf("start", "reward gold"), StageTrace.take(), "an Int does not read as a String, so main starts over") + world.stop() + } + + @Test + fun `a local that became a Float reads the saved Int as a number`() = runTest { + fun version(reward: String) = $$""" + @State + suspend fun main() { + log("start") + val reward = $$reward + checkpoint("reward") + log("reward $reward") + await("x") + } + """ + val world = StageWorld(this, compiler.compile("floats", version("100"))) + world.launch("main") + assertEquals(listOf("start", "reward 100"), StageTrace.take()) + + world.restart(compiler.compile("floats", version("100.5f"))) + assertEquals(listOf("reward 100.0"), StageTrace.take(), "the NBT number converts, so main resumes after the checkpoint") + world.stop() + } + + @Test + fun `an argument that no longer reads as its type fails the story with its name`() = runTest { + fun version(type: String, value: String) = $$""" + @State + suspend fun pay(amount: $$type) { + log("pay $amount") + await("pay") + } + + @State + suspend fun main() = pay($$value) + """ + val world = StageWorld(this, compiler.compile("arguments", version("Int", "100"))) + world.launch("main") + assertEquals(listOf("pay 100"), StageTrace.take()) + + world.restart(compiler.compile("arguments", version("String", "\"gold\""))) + assertEquals(emptyList(), StageTrace.take()) + assertTrue(world.runs.keys.isEmpty(), "the story cannot go on with an argument it cannot read") + world.stop() + } + + @Test + fun `stages of a node script compile with the definition nodes have in the game`() { + val compiled = compiler.compileAs("arena.node.kts", $$""" + @file:Import("./enums/Lane.kts") + + @State("main") + suspend fun main() { + Lane.entries.forEach { lane -> + async("lane_${lane.name}") { log(lane.name) } + } + } + """, mapOf("enums/Lane.kts" to "enum class Lane { LEFT, RIGHT }")).getOrThrow() + val type = compiled.type.java + assertTrue(type.isAnnotationPresent(StageScript::class.java)) + assertEquals(setOf("main", "main\$async#1"), type.declaredMethods.mapNotNull { it.getAnnotation(StageEntry::class.java)?.name }.toSet()) + } +} diff --git a/addons/compiler/src/test/kotlin/StageGraphTest.kt b/addons/compiler/src/test/kotlin/StageGraphTest.kt new file mode 100644 index 000000000..776dd1012 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageGraphTest.kt @@ -0,0 +1,302 @@ +import kotlinx.coroutines.test.runTest +import ru.hollowhorizon.hollowengine.addons.narrative.graph.GeneratedStory +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphCompiler +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryLink +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryCallKind +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryOperators +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryOption +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryPinType +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryPins +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryVariable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** A graph is generated into stages, so it runs, resumes and fails to compile as a hand-written script does. */ +class StageGraphTest : StageTest() { + private fun call(id: String, function: String, vararg arguments: Pair) = + StoryNode.Call(id, function, arguments = mapOf(*arguments)) + + private fun log(id: String, text: String) = call(id, "log", "event" to text) + + private fun next(from: String, to: String, pin: String = StoryPins.NEXT) = StoryLink(from, pin, to, StoryPins.IN) + + private fun generate(graph: StoryGraph): GeneratedStory = + StoryGraphCompiler.compile(StoryGraph.decode(graph.encode())).also { assertEquals(emptyList(), it.problems) } + + /** start β†’ count β†’ (trust < 3 ? count : done), with a wait inside the loop. */ + private val loop = StoryGraph( + variables = listOf(StoryVariable("trust", "0")), + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Assign("add", "trust", "trust + 1"), + log("say", "\"trust \$trust\""), + call("wait", "await", "name" to "\"step\""), + StoryNode.Branch("more", "trust < 3"), + log("done", "\"done\""), + ), + links = listOf( + next("s", "add"), next("add", "say"), next("say", "wait"), next("wait", "more"), + next("more", "add", StoryPins.THEN), next("more", "done", StoryPins.ELSE), + ), + ) + + @Test + fun `a reroute changes nothing about the story it sits in`() { + val plain = StoryGraph(nodes = listOf(StoryNode.Start("s", "main"), log("a", "\"a\"")), links = listOf(next("s", "a"))) + val through = plain.copy( + nodes = plain.nodes + StoryNode.Reroute("r", flow = true), + links = listOf(next("s", "r"), next("r", "a")), + ) + assertEquals(generate(plain).code, generate(through).code) + } + + @Test + fun `values of operators reach a command through a reroute`() = runTest { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Pure("sum", "add", mapOf("a" to "2", "b" to "3")), + StoryNode.Pure("text", "to-text"), + StoryNode.Reroute("r", flow = false), + call("say", "log"), + ), + links = listOf( + next("s", "say"), + StoryLink("sum", "result", "r", StoryPins.VALUE), + StoryLink("r", StoryPins.VALUE, "text", "value"), + StoryLink("text", "result", "say", "event"), + ), + ) + val world = world("operators", generate(graph).code) + world.launch("main") + assertEquals(listOf("5.0"), StageTrace.take()) + } + + @Test + fun `a for each node runs its body for every item`() = runTest { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Loop("each", "listOf(\"a\", \"b\")"), + call("say", "log"), + log("done", "\"done\""), + ), + links = listOf( + next("s", "each"), + next("each", "say", StoryPins.BODY), + next("each", "done"), + StoryLink("each", StoryPins.ITEM, "say", "event"), + ), + ) + val world = world("for-each", generate(graph).code) + world.launch("main") + assertEquals(listOf("a", "b", "done"), StageTrace.take()) + } + + /** Operators are templates of text, so nothing but compiling them says they mean anything. */ + @Test + fun `every operator compiles`() = runTest { + val nodes = ArrayList() + val links = ArrayList() + nodes += StoryNode.Start("s", "main") + var previous = "s" + StoryOperators.all.forEachIndexed { index, operator -> + val arguments = operator.inputs.filter { it.default.isEmpty() }.associate { input -> + input.name to when (input.type) { + StoryPinType.LIST -> "listOf(\"a\")" + StoryPinType.MAP -> "mapOf(\"a\" to \"b\")" + else -> "\"a\"" + } + } + nodes += StoryNode.Pure("op$index", operator.id, arguments) + operator.outputs.forEachIndexed { at, output -> + val reader = "read${index}_$at" + nodes += call(reader, "log") + links += next(previous, reader) + links += StoryLink("op$index", output.name, reader, "event") + previous = reader + } + } + val code = StoryGraphCompiler.compile(StoryGraph(nodes = nodes, links = links), TestCommands()).code + world("operators-all", code) + } + + /** A write outside the flow runs where its value is read, so a variable needs no place in the steps. */ + @Test + fun `a variable is written when the value of its node is read`() = runTest { + val graph = StoryGraph( + variables = listOf(StoryVariable("x", "0.0", "Double")), + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Assign("write", "x", "5.0"), + call("first", "log"), + call("second", "log", "event" to "\"x=\$x\""), + ), + links = listOf(next("s", "first"), next("first", "second"), StoryLink("write", StoryPins.VALUE, "first", "event")), + ) + val world = world("assign", StoryGraphCompiler.compile(graph, TestCommands()).code) + world.launch("main") + world.settle() + assertEquals(listOf("5.0", "x=5.0"), StageTrace.take()) + } + + /** Every type a variable can be declared with has to compile and be saved by a story. */ + @Test + fun `every type of a variable is declared and saved`() = runTest { + val variables = listOf( + "Double" to "0.0", "String" to "\"\"", "Boolean" to "false", "String?" to "", "Entity?" to "", + "List" to "listOf(1.0)", "Map" to "mapOf(\"a\" to 1.0)", + ).mapIndexed { index, (type, initial) -> StoryVariable("value$index", initial, type) } + val graph = StoryGraph( + variables = variables, + nodes = listOf(StoryNode.Start("s", "main"), log("first", "\"done\"")), + links = listOf(next("s", "first")), + ) + val world = world("types", generate(graph).code) + world.launch("main") + world.settle() + assertEquals(listOf("done"), StageTrace.take()) + } + + @Test + fun `a loop of the graph runs and resumes after a restart`() = runTest { + val world = world("loop", generate(loop).code) + world.launch("main") + assertEquals(listOf("trust 1"), StageTrace.take()) + + world.restart() + assertEquals(emptyList(), StageTrace.take()) + StageTrace.release("step") + world.settle() + assertEquals(listOf("trust 2", "trust 3", "done"), StageTrace.take()) + } + + @Test + fun `a long loop does not pile up frames`() = runTest { + val graph = StoryGraph( + variables = listOf(StoryVariable("count", "0")), + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Assign("add", "count", "count + 1"), + StoryNode.Branch("more", "count < 5000"), + log("done", "\"done \$count\""), + ), + links = listOf( + next("s", "add"), next("add", "more"), + next("more", "add", StoryPins.THEN), next("more", "done", StoryPins.ELSE), + ), + ) + world("long", generate(graph).code).launch("main") + assertEquals(listOf("done 5000"), StageTrace.take()) + } + + @Test + fun `values travel between stages and through a code node`() = runTest { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Code( + "made", "doubled = base * 2\nlog(\"code \$base\")", + inputs = mapOf("base" to "21"), outputs = mapOf("doubled" to "Int"), + ), + StoryNode.Branch("again", "false"), + call("wait", "await", "name" to "\"after\""), + call("show", "log"), + StoryNode.Expression("text", "\"\" + 1"), + StoryNode.Code("sum", "log(\"sum \$value\")", inputs = mapOf("value" to "")), + ), + links = listOf( + next("s", "made"), next("made", "again"), + next("again", "wait", StoryPins.THEN), next("again", "wait", StoryPins.ELSE), + next("wait", "show"), next("show", "sum"), + StoryLink("text", StoryPins.VALUE, "show", "event"), + StoryLink("made", "doubled", "sum", "value"), + ), + ) + val world = world("values", generate(graph).code) + world.launch("main") + assertEquals(listOf("code 21"), StageTrace.take()) + world.restart() + world.release("after") + assertEquals(listOf("1", "sum 42"), StageTrace.take()) + } + + @Test + fun `a choice compiles into branches of its options`() { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Choose("ask", listOf(StoryOption("Π”Π° \"Ρ‚ΠΎΡ‡Π½ΠΎ\""), StoryOption("Π¦Π΅Π½Π°: $5"))), + log("yes", "\"yes\""), + log("after", "\"after\""), + ), + links = listOf( + next("s", "ask"), next("ask", "yes", StoryPins.option(0)), + next("ask", "after", StoryPins.option(1)), next("yes", "after"), + ), + ) + val story = generate(graph) + compiler.compile("choice", story.code) + assertEquals(1, Regex("suspend fun block_after").findAll(story.code).count()) + } + + @Test + fun `a line with a speaker and the name of the player compiles`() { + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Say("hello", "Guide", "Hello, {player}!")), + links = listOf(next("s", "hello")), + ) + compiler.compile("line", generate(graph).code) + } + + @Test + fun `steps that do not pause share a stage and a pause gets its own`() { + val graph = StoryGraph( + variables = listOf(StoryVariable("count", "0")), + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Assign("first", "count", "1"), + log("second", "\"instant\""), + StoryNode.Assign("third", "count", "2"), + call("wait", "await", "name" to "\"step\""), + StoryNode.Say("line", text = "Hello"), + ), + links = listOf( + next("s", "first"), next("first", "second"), next("second", "third"), + next("third", "wait"), next("wait", "line"), + ), + ) + val kinds: (StoryNode.Call) -> StoryCallKind = { + if (it.function == "log") StoryCallKind.INSTANT else StoryCallKind.SUSPENDING + } + val stages = Regex("suspend fun (step_\\w+)").findAll(StoryGraphCompiler.compile(graph, TestCommands(kinds)).code) + assertEquals(listOf("step_first", "step_wait", "step_line"), stages.map { it.groupValues[1] }.toList()) + } + + @Test + fun `an error of the Kotlin compiler leads back to its node`() { + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), log("fine", "\"ok\""), call("broken", "log", "event" to "missing")), + links = listOf(next("s", "fine"), next("fine", "broken")), + ) + val story = generate(graph) + val failure = compiler.errors(story.code, "located") + val line = story.code.lines().indexOfFirst { "missing" in it } + 1 + assertEquals("broken", story.nodeAt(line)) + assertTrue("missing" in failure, failure) + } + + @Test + fun `problems of the graph are reported before Kotlin sees them`() { + val story = StoryGraphCompiler.compile( + StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Assign("set", "nothing", "1"), call("a", "x"), call("b", "y")), + links = listOf(next("s", "set"), next("set", "a"), next("set", "b"), StoryLink("gone", "value", "a", "x")), + ) + ) + assertEquals(listOf(null, "set", "set"), story.problems.map { it.node }.sortedBy { it.orEmpty() }) + } +} diff --git a/addons/compiler/src/test/kotlin/StageJournalTest.kt b/addons/compiler/src/test/kotlin/StageJournalTest.kt new file mode 100644 index 000000000..9369bfdee --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageJournalTest.kt @@ -0,0 +1,471 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Actions in the journal: what is undone when, rewinds to checkpoints, labels, a restart in between. */ +class StageJournalTest : StageTest() { + @Test + fun `a temporary action stands until the next checkpoint of its run and a permanent one stays`() = runTest { + val world = world("temporary", PLACE + $$""" + @State + suspend fun fight() { + temporary { place("barricade") } + place("banner") + await("win") + } + + @State + suspend fun main() { + fight() + log("after ${StageTrace.blocks}") + checkpoint("won") + log("passed ${StageTrace.blocks}") + } + """ + ) + world.launch("main") + assertEquals(listOf("place barricade", "place banner"), StageTrace.take()) + + world.release("win") + assertEquals(listOf("after [barricade, banner]", "undo barricade", "passed [banner]"), StageTrace.take()) + world.stop() + } + + @Test + fun `a temporary action until a named checkpoint goes when any run of the story passes it`() = runTest { + val world = world("until", PLACE + $$""" + @State + suspend fun main() { + temporary(until = "defeated") { place("gate") } + checkpoint("arena") + log("arena ${StageTrace.blocks}") + async("boss") { + await("kill") + checkpoint("defeated") + log("defeated ${StageTrace.blocks}") + } + await("end") + } + """ + ) + world.launch("main") + assertEquals(listOf("place gate", "arena [gate]"), StageTrace.take(), "another checkpoint leaves it standing") + + world.release("kill") + assertEquals(listOf("undo gate", "defeated []"), StageTrace.take()) + world.stop() + } + + @Test + fun `a segment that repeats after a restart undoes its temporary actions first`() = runTest { + val world = world("repeat", PLACE + """ + @State + suspend fun main() { + temporary { place("barricade") } + await("x") + log("done") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("place barricade", "undo barricade", "place barricade"), StageTrace.take()) + + world.release("x") + assertEquals(listOf("done", "undo barricade"), StageTrace.take()) + assertEquals(emptySet(), StageTrace.blocks) + world.stop() + } + + @Test + fun `an action finished before a restart is not performed again, and its one record still undoes it`() = runTest { + val world = world("finished", PLACE + """ + @State + suspend fun main() { + place("door") + await("x") + log("done") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("place door"), StageTrace.take(), "the restart goes on after the action") + + world.runs["main"]!!.rewind("main") + world.settle() + assertEquals(listOf("undo door", "place door"), StageTrace.take()) + world.stop() + } + + @Test + fun `an action a restart interrupts is finished under its record, with the snapshot it took first`() = runTest { + val world = world("interrupted", $$""" + @Action + suspend fun walk(name: String) = action { + val there = capture { log("capture $name"); name in StageTrace.blocks } + undo { + if (!there) StageTrace.blocks -= name + log("undo $name") + } + perform { + log("walk $name") + StageTrace.await(name) + StageTrace.blocks += name + } + } + + @State + suspend fun main() { + walk("gate") + log("arrived") + await("x") + } + """ + ) + world.launch("main") + assertEquals(listOf("capture gate", "walk gate"), StageTrace.take()) + + world.restart() + assertEquals(listOf("walk gate"), StageTrace.take(), "the walk goes on without a new capture") + + world.release("gate") + assertEquals(listOf("arrived"), StageTrace.take()) + StageTrace.hold("gate") + world.runs["main"]!!.rewind("main") + world.settle() + assertEquals(listOf("undo gate", "capture gate", "walk gate"), StageTrace.take(), "one record, undone once") + assertEquals(emptySet(), StageTrace.blocks) + world.stop() + } + + @Test + fun `a restart before an action wrote its record repeats the segment before the call`() = runTest { + val world = world("unwritten", PLACE + """ + suspend fun target(): String { + StageTrace.await("aim") + return "door" + } + + @State + suspend fun main() { + log("start") + place(target()) + await("x") + } + """ + ) + world.launch("main") + world.restart() + world.release("aim") + assertEquals(listOf("start", "start", "place door"), StageTrace.take()) + world.stop() + } + + @Test + fun `the result of an action is kept for a restart after it`() = runTest { + val world = world("counted", $$""" + @Action + suspend fun count(name: String): Int = action { + undo { } + perform { + log("count $name") + 42 + } + } + + @State + suspend fun main() { + val counted = count("sheep") + await("x") + log("got $counted") + } + """ + ) + world.launch("main") + world.restart() + world.release("x") + assertEquals(listOf("count sheep", "got 42"), StageTrace.take()) + world.stop() + } + + @Test + fun `a rewind to a checkpoint undoes what came after it and keeps what once did`() = runTest { + val world = world("rewind", PLACE + """ + @State + suspend fun main() { + place("door") + checkpoint("gate") + once("reward") { log("reward") } + place("bridge") + await("x") + log("end") + } + """ + ) + val story = world.launch("main") + assertEquals(listOf("place door", "reward", "place bridge"), StageTrace.take()) + + story.rewind("gate") + world.settle() + assertEquals(listOf("undo bridge", "place bridge"), StageTrace.take(), "the reward is not given twice") + + world.release("x") + assertEquals(listOf("end"), StageTrace.take()) + assertEquals(setOf("door", "bridge"), StageTrace.blocks) + world.stop() + } + + @Test + fun `rewind without a name goes back to the latest checkpoint, past the stage calls after it`() = runTest { + val world = world("latest", PLACE + """ + @State + suspend fun step() = log("step") + + @State + suspend fun main() { + place("door") + checkpoint("gate") + step() + place("bridge") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + log("done") + } + """ + ) + StageTrace.token = "fail" + world.launch("main") + assertEquals( + listOf("place door", "step", "place bridge", "undo bridge", "step", "place bridge", "done"), + StageTrace.take(), + ) + world.stop() + } + + @Test + fun `rewind with no checkpoint before it starts its run over`() = runTest { + val world = world("segment", PLACE + """ + @State + suspend fun attempt() { + place("trap") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + log("survived") + } + + @State + suspend fun main() { + attempt() + log("done") + } + """ + ) + StageTrace.token = "fail" + world.launch("main") + assertEquals(listOf("place trap", "undo trap", "place trap", "survived", "done"), StageTrace.take()) + world.stop() + } + + @Test + fun `labels put back what they captured on every rewind to their checkpoint`() = runTest { + val world = world("labels", PLACE + """ + @Action + suspend fun CheckpointScope.flag(name: String) = label { + val had = capture { name in StageTrace.blocks } + undo { if (had) StageTrace.blocks += name else StageTrace.blocks -= name } + } + + @State + suspend fun main() { + StageTrace.blocks += "flag" + checkpoint("arena") { flag("flag") } + await("drop") + StageTrace.blocks -= "flag" + log("dropped") + await("x") + } + """ + ) + val story = world.launch("main") + repeat(2) { round -> + world.release("drop") + assertEquals(listOf("dropped"), StageTrace.take()) + assertEquals(emptySet(), StageTrace.blocks) + + StageTrace.hold("drop") + story.rewind("arena") + world.settle() + assertEquals(setOf("flag"), StageTrace.blocks, "round $round puts the flag back") + } + world.stop() + } + + @Test + fun `the journal outlives a restart and still undoes what was performed before it`() = runTest { + val world = world("restart", PLACE + """ + @State + suspend fun main() { + place("door") + checkpoint("placed") + await("x") + } + """ + ) + world.launch("main") + world.restart() + assertEquals(listOf("place door"), StageTrace.take()) + + world.runs["main"]!!.rewind("main") + world.settle() + assertEquals(listOf("undo door", "place door"), StageTrace.take(), "the story starts over from its entry") + world.stop() + } + + @Test + fun `a rewind to before a branch removes the branch along with what it performed`() = runTest { + val world = world("branches", PLACE + """ + @State + suspend fun main() { + checkpoint("before") + async("guard") { + place("tower") + await("g") + } + place("wall") + await("x") + } + """ + ) + val story = world.launch("main") + assertEquals(listOf("place wall", "place tower"), StageTrace.take()) + + story.rewind("before") + world.settle() + assertEquals(listOf("undo tower", "undo wall", "place wall", "place tower"), StageTrace.take()) + assertEquals(listOf("main", "before", "guard"), story.checkpoints.map { it.first }) + world.stop() + } + + @Test + fun `remember keeps its value across a rewind`() = runTest { + val world = world("remember", $$""" + @State + suspend fun main() { + checkpoint("start") + val roll = remember("roll") { StageTrace.token } + log("roll $roll") + await("x") + } + """ + ) + StageTrace.token = "first" + val story = world.launch("main") + StageTrace.token = "second" + story.rewind("start") + world.settle() + assertEquals(listOf("roll first", "roll first"), StageTrace.take()) + world.stop() + } + + @Test + fun `an unsaved parameter gets its default when the journal calls the action again`() = runTest { + val world = world("unsaved", $$""" + @Action + suspend fun mark(name: String, @Unsaved note: String = "default") = action { + undo { log("undo $name $note") } + perform { log("mark $name $note") } + } + + @State + suspend fun main() { + temporary { mark("a", "given") } + log("done") + } + """ + ) + world.launch("main") + assertEquals(listOf("mark a given", "done", "undo a default"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch takes back its temporary actions when the stage that started it returns`() = runTest { + val world = world("branchTemporary", PLACE + $$""" + @State + suspend fun fight() { + async("guards") { + temporary { place("guard") } + await("never") + } + await("win") + } + + @State + suspend fun main() { + fight() + log("after ${StageTrace.blocks}") + } + """ + ) + world.launch("main") + world.release("win") + assertEquals(listOf("place guard", "undo guard", "after []"), StageTrace.take()) + world.stop() + } + + @Test + fun `rewind in a branch takes back the branch alone`() = runTest { + val world = world("branchRewind", PLACE + """ + @State + suspend fun main() { + place("wall") + async("guard") { + place("tower") + checkpoint("posted") + place("flag") + if (StageTrace.token == "fail") { + StageTrace.token = "" + rewind() + } + await("g") + } + await("x") + } + """ + ) + StageTrace.token = "fail" + world.launch("main") + assertEquals(listOf("place wall", "place tower", "place flag", "undo flag", "place flag"), StageTrace.take()) + assertEquals(setOf("wall", "tower", "flag"), StageTrace.blocks) + world.stop() + } + + private companion object { + /** An action that puts [name] into the world and takes it back unless someone else did it first. */ + const val PLACE = $$""" + @Action + suspend fun place(name: String) = action { + val existed = capture { name in StageTrace.blocks } + undo { + if (name !in StageTrace.blocks) conflict("$name is gone") + if (!existed) StageTrace.blocks -= name + log("undo $name") + } + fastForward { + StageTrace.blocks += name + log("forward $name") + } + perform { + StageTrace.blocks += name + log("place $name") + } + } + + """ + } +} diff --git a/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt new file mode 100644 index 000000000..f3cdf4f9d --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageLambdaSafetyTest.kt @@ -0,0 +1,89 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertEquals + +/** A moved stage must not retain hidden closures into the function that created it. */ +class StageLambdaSafetyTest : StageTest() { + @Test + fun `a branch calls an enclosing local function only when what it uses can be saved`() { + val errors = compiler.errors(""" + @State + suspend fun main() { + var lock: Any? = null + suspend fun playSing(name: String) { + lock = name + async("resetToIdle") { await(name) } + } + listOf("left", "right").forEach { direction -> + async("input_" + direction) { playSing(direction) } + } + await("end") + } + """) + assertContains(errors, "local function 'playSing'") + assertContains(errors, "'lock', which cannot be saved") + assertFalse(errors.contains("Exception while generating code"), errors) + } + + @Test + fun `an enclosing local function reference has the same diagnostic as a call`() { + val errors = compiler.errors(""" + fun start(runs: StoryRuns, name: String) { + fun greet() = log(name) + runs.launch("main") { listOf(1).forEach { ::greet.invoke() } } + } + """) + assertContains(errors, "local function 'greet'") + assertFalse(errors.contains("Exception while generating code"), errors) + } + + @Test + fun `ordinary function values cannot masquerade as generated stage lambdas`() { + val errors = compiler.errors(""" + suspend fun helper() {} + fun start(runs: StoryRuns) { runs.launch("main", ::helper) } + """) + assertContains(errors, "a regular function value has no saved stage") + val stored = compiler.errors(""" + fun start(runs: StoryRuns) { + val body: @State suspend () -> Unit = { log("hello") } + runs.launch("main", body) + } + """) + assertContains(stored, "a regular function value has no saved stage") + } + + @Test + fun `a branch cannot construct a local class declared outside it`() { + assertContains(compiler.errors(""" + fun start(runs: StoryRuns, text: String) { + class Greeting { fun show() = log(text) } + runs.launch("main") { Greeting().show() } + } + """), "local class 'Greeting'") + } + + @Test + fun `a branch can declare its own local functions and call top level helpers`() = runTest { + val world = world("localHelpers", """ + fun greet(name: String) = log(name) + + @State + suspend fun main() { + val name = "left" + async("input") { + fun sing() = greet(name) + sing() + await("end") + } + await("end") + } + """) + world.launch("main") + world.restart() + assertEquals(listOf("left", "left"), StageTrace.take()) + world.stop() + } +} diff --git a/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt new file mode 100644 index 000000000..d847aefbc --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageSharedVarsTest.kt @@ -0,0 +1,212 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +/** A `var` of a stage that its `@State` lambdas capture is one value for all of them, and the story saves it. */ +class StageSharedVarsTest : StageTest() { + @Test + fun `a branch changes a var of its stage, and the value survives a restart`() = runTest { + val world = world("sharedCounter", $$""" + @State + suspend fun main() { + var count = 0 + async("counter") { + count++ + log("branch $count") + await("more") + count++ + log("branch $count") + } + checkpoint("started") + await("check") + log("main $count") + } + """ + ) + world.launch("main") + world.release("more") + assertEquals(listOf("branch 1", "branch 2"), StageTrace.take()) + + world.restart() + world.release("check") + assertEquals(listOf("main 2"), StageTrace.take(), "main reads the value the branch left") + world.stop() + } + + @Test + fun `a segment that repeats keeps sharing the box with the branch it started before`() = runTest { + val world = world("sharedRepeat", $$""" + @State + suspend fun main() { + var count = 0 + async("counter") { + await("tick") + count++ + log("branch $count") + } + await("check") + log("main $count") + } + """ + ) + world.launch("main") + world.restart() + world.release("tick") + world.release("check") + assertEquals(listOf("branch 1", "main 1"), StageTrace.take()) + world.stop() + } + + @Test + fun `every loop iteration declares a var of its own`() = runTest { + val world = world("sharedLoop", $$""" + @State + suspend fun main() { + for (i in 0 until 2) { + var hits = i * 10 + async("b$i") { + hits++ + log("b$i $hits") + } + } + await("x") + } + """ + ) + world.launch("main") + assertEquals(listOf("b0 1", "b1 11"), StageTrace.take()) + world.stop() + } + + @Test + fun `a var of a stage lambda is shared with the lambdas inside it`() = runTest { + val world = world("sharedNested", $$""" + @State + suspend fun main() { + async("outer") { + var seen = "none" + async("inner") { seen = "inner" } + await("x") + log("outer sees $seen") + } + await("y") + } + """ + ) + world.launch("main") + world.release("x") + assertEquals(listOf("outer sees inner"), StageTrace.take()) + world.stop() + } + + @Test + fun `a branch calls a local function of its stage that shares a var and starts branches of its own`() = runTest { + val world = world("sharedLocal", $$""" + @State + suspend fun main() { + var last = "none" + suspend fun sing(name: String) { + log("sing $name after $last") + last = name + async("reset") { + await("r") + log("reset $name") + } + } + listOf("left", "right").forEach { direction -> + async("input_$direction") { + sing(direction) + await("hold") + } + } + await("end") + log("last $last") + } + """ + ) + world.launch("main") + assertEquals(listOf("sing left after none", "sing right after left"), StageTrace.take()) + + world.restart() + assertEquals(listOf("sing left after none", "sing right after left"), StageTrace.take(), "the segment repeats with its branches") + + world.release("r") + world.release("end") + assertEquals(listOf("reset left", "reset right", "last right"), StageTrace.take()) + world.stop() + } + + @Test + fun `a shared var can hold the handle of a branch, which a restart finds again`() = runTest { + val world = world("sharedHandle", $$""" + @State + suspend fun main() { + var timer: StoryHandle<*>? = null + suspend fun sing(name: String) { + timer?.cancel() + timer = async("reset") { + await("r$name") + log("reset $name") + } + } + async("input") { + sing("left") + await("next") + sing("right") + checkpoint("sung") + await("hold") + } + checkpoint("listening") + await("end") + log("timer ${timer?.key}") + } + """ + ) + world.launch("main") + world.release("next") + world.restart() + world.release("rleft") + world.release("rright") + world.release("end") + assertEquals(listOf("reset right", "timer main/input/reset"), StageTrace.take(), "the cancelled timer stays cancelled") + world.stop() + } + + @Test + fun `a story started from a stage cannot share its vars`() { + val errors = compiler.errors(""" + fun start(runs: StoryRuns) {} + + @State + suspend fun main() { + var count = 0 + context.server.launchStory("other") { count++ } + } + """) + assertContains(errors, "keeps copies of what it captures") + } + + @Test + fun `a var declared outside a stage cannot be shared`() { + val errors = compiler.errors(""" + fun start(runs: StoryRuns) { + var count = 0 + runs.launch("story") { count++ } + } + """) + assertContains(errors, "only with the @State function or lambda that declares it directly") + } + + @Test + fun `a shared var must hold a value the story can save`() { + val errors = compiler.errors(""" + @State + suspend fun main() { + var lock = Any() + async("b") { lock = Any() } + } + """) + assertContains(errors, "its value is saved") + } +} diff --git a/addons/compiler/src/test/kotlin/StageTestSupport.kt b/addons/compiler/src/test/kotlin/StageTestSupport.kt new file mode 100644 index 000000000..cc08a0e47 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageTestSupport.kt @@ -0,0 +1,233 @@ +import StageTrace.release +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryHandle +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryInstances +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryOwner +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRuns +import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl +import ru.hollowhorizon.hollowengine.common.scripting.DefaultScriptDefinitions +import ru.hollowhorizon.hollowengine.common.scripting.NODE_SCRIPT_EXTENSION +import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript +import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings +import java.io.File +import kotlin.coroutines.ContinuationInterceptor +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.io.path.createTempDirectory +import kotlin.test.fail + +/** The base of the scripts stage tests compile. */ +abstract class StageTestScript + +/** What test scripts write down and wait for; it outlives a simulated restart, like the world does. */ +object StageTrace { + val events = mutableListOf() + + /** A value scripts read and tests change, to make code behave differently after a restart. */ + @JvmStatic + var token: String = "" + + /** What actions of test scripts place in the world; like the world, it outlives a restart and is not rewound. */ + @JvmStatic + val blocks = linkedSetOf() + private val released = HashSet() + private val waiting = HashMap>>() + + @JvmStatic + fun log(event: String) { + events += event + } + + /** Suspends until the test calls [release] with [name]; returns at once if it already did. */ + @JvmStatic + suspend fun await(name: String) { + if (name in released) return + CompletableDeferred().also { waiting.getOrPut(name, ::ArrayList) += it }.await() + } + + fun release(name: String) { + released += name + waiting.remove(name)?.forEach { it.complete(Unit) } + } + + /** Cancels what waits in [await] with [name], as an NPC channel that another owner takes cancels its action. */ + fun cancel(name: String) { + waiting.remove(name)?.forEach { it.cancel(CancellationException("'$name' was taken")) } + } + + /** Makes [await] with [name] wait again, as before it was released. */ + fun hold(name: String) { + released -= name + } + + fun take(): List = events.toList().also { events.clear() } + + fun reset() { + events.clear() + token = "" + blocks.clear() + released.clear() + waiting.clear() + } +} + +/** What the narrative addon adds to node scripts in the game. */ +private val NARRATIVE_IMPORTS = listOf( + "ru.hollowhorizon.hollowengine.addons.narrative.story.*", + "ru.hollowhorizon.hollowengine.addons.narrative.npcs.*", +) + +/** Compiles stage scripts with the real compiler and plugin, as `.stage.kts` or as a node of the game. */ +class StageCompiler : AutoCloseable { + val environment = ScriptingEnvironmentImpl( + javaHome = File(System.getProperty("java.home")), + classpath = System.getProperty("java.class.path").split(File.pathSeparator).filter(String::isNotBlank) + .map(::File).filter(File::exists), + scriptTypes = listOf( + ScriptClassProvider( + extension = ".stage.kts", + baseClass = StageTestScript::class.qualifiedName!!, + defaultImports = listOf( + "ru.hollowhorizon.hollowengine.addons.narrative.story.*", + "StageTrace.log", + "StageTrace.await" + ), + ), + DefaultScriptDefinitions.providerFor("story.$NODE_SCRIPT_EXTENSION")!!.let { node -> + node.copy(defaultImports = node.defaultImports + NARRATIVE_IMPORTS + "StageTrace.log" + "StageTrace.await") + }, + ), + mappings = Mappings.EMPTY, + ).also { ScriptingEnvironment.INSTANCE = it } + + private val sources = createTempDirectory("stages").toFile() + + /** Compiles [code] from a file, as the game compiles scripts, so the same [name] keeps its path across versions. */ + fun compile(name: String, code: String): CompiledScript = + compileFile(name, code).getOrElse { fail("Expected the script to compile: $it") } + + /** The errors compiling [code] reports; fails if it compiles. */ + fun errors(code: String, name: String = "broken"): String { + val failure = compileFile(name, code).exceptionOrNull() ?: fail("Expected the script not to compile:\n$code") + return failure.toString() + } + + /** Compiles [code] as the file [fileName], with [others] written beside it for it to import. */ + fun compileAs(fileName: String, code: String, others: Map = emptyMap()): Result { + others.forEach { (name, text) -> sources.resolve(name).apply { parentFile.mkdirs() }.writeText(text.trimIndent()) } + return environment.compiler.compile(sources.resolve(fileName).apply { writeText(code.trimIndent()) }) + } + + private fun compileFile(name: String, code: String): Result = compileAs("$name.stage.kts", code) + + override fun close() { + sources.deleteRecursively() + sources.deleteRecursively() + ScriptingEnvironment.clear() + environment.close() + File("hollowengine").deleteRecursively() + } +} + +/** + * A script instance with an owner of stories, which can be stopped and brought back from what it saved, the way + * a server restart does: the runs are written out, everything is canceled, a fresh instance of the script is + * started and the runs are read back. + */ +class StageWorld(private val test: TestScope, private var compiled: CompiledScript) { + private class Owner(override val scope: CoroutineScope) : StoryOwner { + override val server: MinecraftServer get() = error("There is no server in stage tests") + override val entity: Entity? get() = null + } + + private var scope = newScope() + var instance: Any = execute() + private set + var runs = StoryRuns(Owner(scope)) + private set + + val key: String get() = StoryInstances.keyOf(instance) + + fun launch(stage: String, key: String = "main"): StoryHandle = + runs.launch(key, this.key, stage).also { settle() } + + /** Calls a function the script declares with [arguments]. */ + fun call(name: String, vararg arguments: Any?): Any? { + val method = instance.javaClass.declaredMethods.first { it.name == name && it.parameterCount == arguments.size } + method.trySetAccessible() + return method.invoke(instance, *arguments).also { settle() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun settle() = test.advanceUntilIdle() + + fun release(name: String) { + StageTrace.release(name) + settle() + } + + /** What the runs would be written to the world save as. */ + fun save(): CompoundTag = runs.save() + + /** + * Stops everything and starts it again from the save, optionally with the script recompiled as [changed]. + * With [startScript] false the stories are read back before the script runs again; [startScript] runs it. + */ + fun restart(changed: CompiledScript? = null, startScript: Boolean = true) { + val saved = save() + scope.cancel() + settle() + changed?.let { compiled = it } + scope = newScope() + if (startScript) instance = execute() + runs = StoryRuns(Owner(scope)) + runs.load(saved) + settle() + } + + fun startScript() { + instance = execute() + settle() + } + + fun stop() { + scope.cancel() + settle() + } + + private fun newScope() = CoroutineScope(test.coroutineContext[ContinuationInterceptor]!! + SupervisorJob()) + + private fun execute(): Any { + val instance = compiled.execute { }.getOrElse { fail("The script failed to start: $it") } + StoryInstances.register(instance, scope) + return instance + } +} + +/** A test that compiles stage scripts, with a fresh trace and compiler for every test. */ +abstract class StageTest { + protected lateinit var compiler: StageCompiler + + @BeforeTest + fun setUpStages() { + StageTrace.reset() + compiler = StageCompiler() + } + + @AfterTest + fun tearDownStages() = compiler.close() + + protected fun TestScope.world(name: String, code: String) = StageWorld(this, compiler.compile(name, code)) +} diff --git a/addons/compiler/src/test/kotlin/StageValueClassTest.kt b/addons/compiler/src/test/kotlin/StageValueClassTest.kt new file mode 100644 index 000000000..af6d2c810 --- /dev/null +++ b/addons/compiler/src/test/kotlin/StageValueClassTest.kt @@ -0,0 +1,26 @@ +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** A value class is a primitive to the JVM, so what a stage holds in place of one cannot be null. */ +class StageValueClassTest : StageTest() { + @Test + fun `a duration can be an argument of an action`() = runTest { + val world = world("durationArgument", """ + import ru.hollowhorizon.hollowengine.addons.narrative.npcs.* + import ru.hollowhorizon.hollowengine.addons.narrative.scene.commands.* + import ru.hollowhorizon.hollowengine.addons.narrative.story.* + import ru.hollowhorizon.hollowengine.common.entities.NpcEntity + + var npc by saved(null) + + @State + suspend fun main() { + if ("yes".length == 2) (npc!!).lookAt(duration = duration("2"), target = storyPlayer()) + log("done") + } + """) + world.launch("main") + assertEquals(listOf("done"), StageTrace.take()) + } +} diff --git a/addons/compiler/src/test/kotlin/StoryTopologyTest.kt b/addons/compiler/src/test/kotlin/StoryTopologyTest.kt new file mode 100644 index 000000000..8492b8eeb --- /dev/null +++ b/addons/compiler/src/test/kotlin/StoryTopologyTest.kt @@ -0,0 +1,432 @@ +import kotlinx.coroutines.test.runTest +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.copyNodes +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeInput +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeNodes +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.renameVariable +import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType +import kotlin.reflect.typeOf +import kotlin.test.* + +/** Commands of tests: what a node says of itself, with every parameter taking a string. */ +internal class TestCommands(private val kinds: (StoryNode.Call) -> StoryCallKind = { StoryCallKind.SUSPENDING }) : + StoryFunctions { + override fun signature(call: StoryNode.Call) = StoryBuiltins.find(call.action.orEmpty()) ?: StoryFunctionSignature( + call.function, call.function, (call.arguments.keys + "event").map { StoryParam(it, typeOf()) }, + returns = call.returns, block = call.block, + ) + + override fun kind(call: StoryNode.Call) = kinds(call) +} + +/** Graphs of the shapes that are easy to get wrong, and the edits that change their shape. */ +class StoryTopologyTest : StageTest() { + private fun call(id: String, function: String, vararg arguments: Pair, returns: String? = null) = + StoryNode.Call(id, function, arguments = mapOf(*arguments), returns = returns) + + private fun log(id: String, text: String = id) = call(id, "log", "event" to "\"$text\"") + + private fun next(from: String, to: String, pin: String = StoryPins.NEXT) = StoryLink(from, pin, to, StoryPins.IN) + + private fun compile(graph: StoryGraph) = StoryGraphCompiler.compile(graph, TestCommands()) + + private fun code(graph: StoryGraph) = compile(graph).also { assertEquals(emptyList(), it.problems) }.code + + @Test + fun `branches that meet go on together`() = runTest { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Branch("b", "true"), + log("a"), + log("c"), + log("merge"), + log("after") + ), + links = listOf( + next("s", "b"), next("b", "a", StoryPins.THEN), next("b", "c", StoryPins.ELSE), + next("a", "merge"), next("c", "merge"), next("merge", "after"), + ), + ) + world("diamond", code(graph)).launch("main") + assertEquals(listOf("a", "merge", "after"), StageTrace.take()) + } + + @Test + fun `a branch inside a branch finds its way back`() = runTest { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), StoryNode.Branch("outer", "true"), StoryNode.Branch("inner", "false"), + log("x"), log("y"), log("z"), log("end"), + ), + links = listOf( + next("s", "outer"), next("outer", "inner", StoryPins.THEN), next("outer", "z", StoryPins.ELSE), + next("inner", "x", StoryPins.THEN), next("inner", "y", StoryPins.ELSE), + next("x", "end"), next("y", "end"), next("z", "end"), + ), + ) + world("nested", code(graph)).launch("main") + assertEquals(listOf("y", "end"), StageTrace.take()) + } + + @Test + fun `a link back repeats what it leads to`() = runTest { + val graph = StoryGraph( + variables = listOf(StoryVariable("count", "0")), + nodes = listOf( + StoryNode.Start("s", "main"), StoryNode.Assign("add", "count", "count + 1"), + call("say", "log", "event" to "\"turn \$count\""), StoryNode.Branch("more", "count < 2"), log("end"), + ), + links = listOf( + next("s", "add"), next("add", "say"), next("say", "more"), + next("more", "add", StoryPins.THEN), next("more", "end", StoryPins.ELSE), + ), + ) + world("back-edge", code(graph)).launch("main") + assertEquals(listOf("turn 1", "turn 2", "end"), StageTrace.take()) + } + + @Test + fun `a chain of reroutes survives losing a link of it`() { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Reroute("r1", flow = true), + StoryNode.Reroute("r2", flow = true), + StoryNode.Reroute("r3", flow = true), + log("a"), + ), + links = listOf(next("s", "r1"), next("r1", "r2"), next("r2", "r3"), next("r3", "a")), + ) + val shorter = graph.removeNodes(setOf("r2")) + assertEquals(setOf("s", "r1", "r3", "a"), shorter.nodes.mapTo(HashSet()) { it.id }) + assertEquals(listOf(listOf("s", "a")), shorter.dissolved().links.map { listOf(it.from, it.to) }) + } + + @Test + fun `a copy links up inside and keeps reading from outside`() { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), StoryNode.Expression("value", "1"), log("a"), call("b", "log"), log("c") + ), + links = listOf( + next("s", "a"), next("a", "b"), next("b", "c"), StoryLink("value", StoryPins.VALUE, "b", "event") + ), + ) + val (copied, ids) = graph.copyNodes(setOf("a", "b"), StoryPoint(10f, 10f)) + val a = ids.getValue("a") + val b = ids.getValue("b") + assertTrue(next(a, b) in copied.links) + assertTrue(StoryLink("value", StoryPins.VALUE, b, "event") in copied.links) + assertFalse(copied.links.any { it.from == b && it.to == "c" }, "flow out of a copy waits to be linked") + assertFalse(copied.links.any { it.from == "s" && it.to == a }, "and so does flow into it") + } + + @Test + fun `a link beats what is typed into the receiver`() { + val call = call("use", "show", "target" to "typed").copy(receiver = "target") + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Expression("value", "linked"), call), + links = listOf(next("s", "use"), StoryLink("value", StoryPins.VALUE, "use", "target")), + ) + val generated = code(graph) + assertTrue("linked" in generated, generated) + assertFalse("typed" in generated, generated) + } + + @Test + fun `a result that can be empty stays empty`() = runTest { + val graph = StoryGraph( + variables = listOf(StoryVariable("found", "null", "String?")), + nodes = listOf( + StoryNode.Start("s", "main"), + call("get", "nothing", returns = "kotlin.String?"), + StoryNode.Assign("keep", "found"), + StoryNode.Branch("empty", "found == null"), + log("none"), + log("some"), + ), + links = listOf( + next("s", "get"), next("get", "keep"), next("keep", "empty"), + next("empty", "none", StoryPins.THEN), next("empty", "some", StoryPins.ELSE), + StoryLink("get", StoryPins.VALUE, "keep", StoryPins.VALUE), + ), + ) + val generated = code(graph) + assertFalse("!!" in generated.substringAfter("found = "), generated) + world("nullable", "$generated\nfun nothing(): String? = null").launch("main") + assertEquals(listOf("none"), StageTrace.take()) + } + + @Test + fun `a required result is asserted to be there`() { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), call("get", "name", returns = "kotlin.String"), call("say", "log") + ), + links = listOf(next("s", "get"), next("get", "say"), StoryLink("get", StoryPins.VALUE, "say", "event")), + ) + assertTrue("!!" in code(graph)) + } + + @Test + fun `moving nodes around does not change what a story means`() { + val graph = StoryGraph(nodes = listOf(StoryNode.Start("s", "main"), log("a")), links = listOf(next("s", "a"))) + val moved = graph.copy(layout = mapOf("a" to StoryPoint(100f, 40f)), collapsed = setOf("a")) + assertEquals(graph.revision(), moved.revision()) + assertNotEquals(graph.revision(), graph.copy(nodes = graph.nodes.dropLast(1) + log("a", "other")).revision()) + } + + @Test + fun `renaming a variable reaches what reads it and nothing else`() { + val graph = StoryGraph( + variables = listOf(StoryVariable("trust", "0")), + nodes = listOf( + StoryNode.Say("say", text = "{trust} and trust, {trust + 1}"), + StoryNode.Assign("set", "trust", "trust + npc.trust + \"trust \$trust\".length + mistrust"), + StoryNode.Code("own", "val trust = 1\nlog(trust)"), + ), + ).renameVariable("trust", "faith") + assertEquals("0", graph.variables.single().initial) + assertEquals("{faith} and trust, {faith + 1}", (graph.node("say") as StoryNode.Say).text) + val set = graph.node("set") as StoryNode.Assign + assertEquals("faith", set.variable) + assertEquals("faith + npc.trust + \"trust \$faith\".length + mistrust", set.value) + assertEquals("val trust = 1\nlog(trust)", (graph.node("own") as StoryNode.Code).code) + } + + @Test + fun `a suspending value keeps a write out of the instant steps next to it`() { + val graph = StoryGraph( + variables = listOf(StoryVariable("value", "\"\"", "String")), + nodes = listOf( + StoryNode.Start("s", "main"), log("first"), StoryNode.Assign("set", "value"), log("last"), + call("ask", "question", returns = "kotlin.String"), + ), + links = listOf( + next("s", "first"), next("first", "set"), next("set", "last"), + StoryLink("ask", StoryPins.VALUE, "set", StoryPins.VALUE), + ), + ) + val kinds = TestCommands { if (it.function == "log") StoryCallKind.INSTANT else StoryCallKind.SUSPENDING } + val stages = Regex("suspend fun (step_\\w+)").findAll(StoryGraphCompiler.compile(graph, kinds).code) + assertTrue("step_set" in stages.map { it.groupValues[1] }.toList()) + } + + @Test + fun `a value from another branch is an error unless it can be null anyway`() { + fun graph(returns: String) = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Branch("b", "true"), + call("get", "name", returns = returns), + call("say", "log"), + log("end"), + ), + links = listOf( + next("s", "b"), next("b", "get", StoryPins.THEN), next("b", "say", StoryPins.ELSE), + StoryLink("get", StoryPins.VALUE, "say", "event"), next("get", "end"), next("say", "end"), + ), + ) + val problem = compile(graph("kotlin.String")).problems.single() + assertEquals("say", problem.node) + assertFalse(problem.warning) + assertTrue(compile(graph("kotlin.String?")).problems.single().warning) + } + + @Test + fun `an item is out of reach after its loop`() { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), StoryNode.Loop("each", "listOf(1)"), log("body"), call("after", "log") + ), + links = listOf( + next("s", "each"), next("each", "body", StoryPins.BODY), next("each", "after"), + StoryLink("each", StoryPins.ITEM, "after", "event"), + ), + ) + assertFalse(compile(graph).problems.single().warning) + } + + @Test + fun `a checkpoint that asks for it takes a restart back to itself`() = runTest { + val world = world( + "checkpoint-restart", """ + @State + suspend fun main() { + log("before") + checkpoint("question", restart = true) + log("asked") + await("answer") + log("answered") + } + """ + ) + world.launch("main") + assertEquals(listOf("before", "asked"), StageTrace.take()) + world.restart() + assertEquals(listOf("asked"), StageTrace.take()) + world.release("answer") + assertEquals(listOf("answered"), StageTrace.take()) + } + + @Test + fun `the next checkpoint turns going back off`() = runTest { + val world = world( + "checkpoint-restart-off", """ + @State + suspend fun main() { + checkpoint("question", restart = true) + log("asked") + await("answer") + checkpoint("answered") + log("after") + await("end") + log("done") + } + """ + ) + world.launch("main") + world.release("answer") + assertEquals(listOf("asked", "after"), StageTrace.take()) + world.restart() + world.release("end") + assertEquals( + listOf("after", "done"), StageTrace.take(), "it goes on from the last checkpoint, not the one before" + ) + } + + @Test + fun `a checkpoint node can ask for it too`() = runTest { + val graph = StoryGraph( + nodes = listOf( + StoryNode.Start("s", "main"), + StoryNode.Call( + "mark", action = "checkpoint", arguments = mapOf("name" to "\"question\"", "restart" to "true") + ), + log("asked"), call("wait", "await", "name" to "\"answer\""), + ), + links = listOf(next("s", "mark"), next("mark", "asked"), next("asked", "wait")), + ) + val world = world("checkpoint-node", code(graph)) + world.launch("main") + assertEquals(listOf("asked"), StageTrace.take()) + world.restart() + assertEquals(listOf("asked"), StageTrace.take()) + } + + @Test + fun `what a command says beats what an old file holds`() { + val stale = StoryNode.Call( + "mark", "old.name", action = "checkpoint", arguments = mapOf("name" to "\"a\""), block = true + ) + val graph = StoryGraph(nodes = listOf(StoryNode.Start("s", "main"), stale), links = listOf(next("s", "mark"))) + val generated = code(graph) + assertTrue("checkpoint(" in generated, generated) + assertFalse("old.name" in generated, generated) + } + + @Test + fun `the parser splits arguments the way Kotlin reads them`() { + assertEquals( + listOf("1", "", "z"), StoryLiterals.vectorParts(StoryLiterals.vector(listOf("1", "", "z"), "vec"), "vec") + ) + assertEquals( + listOf("max(1, 2)", "\"a,b\".length", "3"), + StoryLiterals.vectorParts("vec(max(1, 2), \"a,b\".length, 3)", "vec") + ) + assertEquals(null, StoryKotlin.arguments("a), (b")) + } + + @Test + fun `a list field keeps the Kotlin it was given`() { + val written = "listOf(\"a\", \"b\")" + val type = typeOf>() + assertEquals(written, StoryLiterals.expression(written, type)) + assertEquals(written, StoryLiterals.text(written, type)) + assertEquals(listOf("a", "b"), StoryLiterals.listed(written)) + assertEquals("listOf(\"a\", \"c\")", StoryLiterals.list(listOf("a", "c"))) + assertEquals("", StoryLiterals.list(emptyList())) + } + + @Test + fun `code with no flow is an expression where it is read`() = runTest { + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Code("c", "\"value\""), call("say", "log")), + links = listOf(next("s", "say"), StoryLink("c", StoryPins.VALUE, "say", "event")), + ) + world("code-value", code(graph)).launch("main") + assertEquals(listOf("value"), StageTrace.take()) + } + + @Test + fun `code in the flow has no untyped value to keep`() { + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Code("c", "\"value\""), call("say", "log")), + links = listOf(next("s", "c"), next("c", "say"), StoryLink("c", StoryPins.VALUE, "say", "event")), + ) + assertTrue(compile(graph).problems.any { it.node == "c" || it.node == "say" }) + } + + @Test + fun `a list takes as many inputs as its author added`() { + val list = StoryNode.Pure("l", "list", mapOf("a" to "1", "b" to "2", "c" to "3", "d" to "4"), count = 4) + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), list, StoryNode.Pure("x", "list"), call("use", "log")), + links = listOf( + next("s", "use"), + StoryLink("l", "list", "use", "event"), + StoryLink("x", "list", "l", "d"), + ), + ) + assertContains(compile(graph).code, "listOf((1), (2), (3), (emptyList()))") + val shorter = graph.removeInput("l", 1) + assertEquals(mapOf("a" to "1", "b" to "3", "c" to "4"), (shorter.node("l") as StoryNode.Pure).arguments) + assertEquals("c", shorter.links.single { it.from == "x" }.toPin) + } + + @Test + fun `pins read what they carry without the flow that made it`() { + val list = StoryNode.Pure("l", "list", mapOf("a" to "\"x\""), count = 1) + val loop = StoryNode.Loop("each", "") + val sum = StoryNode.Pure("sum", "add", mapOf("b" to "1")) + val code = StoryNode.Code("c", "", outputs = mapOf("found" to "Int")) + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), list, loop, sum, code), + links = listOf( + next("s", "c"), + next("c", "each"), + StoryLink("l", "list", "each", StoryPins.ITEMS), + StoryLink("c", "found", "sum", "a"), + ), + ) + val pins = StoryGraphCompiler.pins(graph, TestCommands()) + assertEquals("listOf((\"x\"))", pins["l" to "list"]) + assertEquals("(listOf((\"x\"))).iterator().next()", pins["each" to StoryPins.ITEM]) + assertContains(pins.getValue("sum" to "result"), "(null as Int?)") + } + + @Test + fun `only a nullable variable can start empty`() { + val start = listOf(StoryNode.Start("s", "main")) + val empty = StoryGraph(variables = listOf(StoryVariable("a", "", "String?")), nodes = start) + assertContains(code(empty), "var a by saved(null)") + val required = StoryGraph(variables = listOf(StoryVariable("a", "", "String")), nodes = start) + assertTrue(compile(required).problems.isNotEmpty()) + } + + @Test + fun `the parser finds what has a color of its own`() { + val tokens = StoryKotlin.syntax("val a = \"x\" + 1 // note")!!.tokens + assertEquals(TokenType.KEYWORD, tokens.first { it.start == 0 }.type) + assertEquals(listOf(TokenType.KEYWORD, TokenType.STRING, TokenType.NUMERIC_LITERAL, TokenType.COMMENT), tokens.map { it.type }.distinct()) + } + + @Test + fun `broken Kotlin is told from fine Kotlin`() { + listOf( + "x + 1", "listOf(1, 2).size", "val a = 1\nlog(a)", "\"}\"" + ).forEach { assertTrue(StoryKotlin.syntax(it)!!.valid, it) } + listOf("x +", "foo(", "\"open", "} fun other() {").forEach { assertFalse(StoryKotlin.syntax(it)!!.valid, it) } + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt index dc55c3b9c..a58515ce6 100644 --- a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import net.minecraft.client.Minecraft import net.minecraft.client.Screenshot -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneCameraSystem +import ru.hollowhorizon.hollowengine.client.camera.CameraDirector +import ru.hollowhorizon.hollowengine.client.camera.CameraPose import ru.hollowhorizon.hollowengine.client.ui.screen.HollowWaitScreen import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher @@ -47,13 +47,13 @@ object FrameCapture { val waitScreen = pose?.let { HollowWaitScreen(WAIT_HEADING.lang, WAIT_MESSAGE.lang).also(minecraft::setScreen) } - CutsceneCameraSystem.heldPose = pose + CameraDirector.heldPose = pose pending = request try { withTimeoutOrNull(CAPTURE_TIMEOUT) { request.image.await() } } finally { pending = null - CutsceneCameraSystem.heldPose = null + CameraDirector.heldPose = null if (waitScreen != null && minecraft.screen === waitScreen) minecraft.setScreen(previousScreen) } } diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt index 0bd1c65f6..f342e1086 100644 --- a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt @@ -11,7 +11,7 @@ import ru.hollowhorizon.hollowengine.addons.mcp.ToolInputException import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter import ru.hollowhorizon.hollowengine.addons.mcp.contentResult import ru.hollowhorizon.hollowengine.addons.mcp.errorResult -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose +import ru.hollowhorizon.hollowengine.client.camera.CameraPose import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import java.util.Base64 import java.util.Locale diff --git a/addons/narrative/build.gradle.kts b/addons/narrative/build.gradle.kts new file mode 100644 index 000000000..d7a51566d --- /dev/null +++ b/addons/narrative/build.gradle.kts @@ -0,0 +1,30 @@ +base { + archivesName.set("HollowEngineNarrative") +} + +dependencies { + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + val serializationVersion = rootProject.property("serializationVersion") as String + add("testImplementation", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") + add("testImplementation", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") + + val kotlinVersion = rootProject.property("kotlinVersion") as String + val classGraph = "io.github.classgraph:classgraph:4.8.173" + compileOnly("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + compileOnly(classGraph) + testImplementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + testImplementation(classGraph) +} + +tasks.withType().configureEach { useJUnitPlatform() } + +extra["langModId"] = "hollowengine-narrative" +apply(from = rootProject.file("gradle/lang-merge.gradle")) + +tasks.named("processResources") { + dependsOn("mergeLang") + from(layout.buildDirectory.dir("generated/lang")) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt new file mode 100644 index 000000000..ef3c6271e --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/HollowNarrativeAddon.kt @@ -0,0 +1,72 @@ +package ru.hollowhorizon.hollowengine.addons.narrative + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.addons.narrative.client.NarrativeClient +import ru.hollowhorizon.hollowengine.addons.narrative.scene.DialogueCommands +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryCommands +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryScripts +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint +import ru.hollowhorizon.hollowengine.common.addons.extensions +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent +import ru.hollowhorizon.hollowengine.common.scripting.* +import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadScriptsEvent +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptTranslators +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +/** + * Dialogues, stories and cutscenes. NPCs and their action channels stay in the engine; + * this addon builds stories on top of them. + */ +class HollowNarrativeAddon : HollowAddonEntrypoint { + override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { + ScriptTranslators.register(StoryScripts.EXTENSION, StoryScripts) + context.extensions.onUnload { + ScriptTranslators.unregister(StoryScripts.EXTENSION) + StoryScripts.stop() + } + if (isPhysicalClient) NarrativeClient.register(context.extensions) + } + + @SubscribeEvent + fun addDefaultImports(event: ScriptDefaultImportsEvent) { + when (event.suffix) { + RELOAD_SCRIPT_EXTENSION -> event.defaultImports += RELOAD_IMPORTS + STORY_PACKAGES + NODE_SCRIPT_EXTENSION -> event.defaultImports += NODE_IMPORTS + STORY_PACKAGES + STARTUP_SCRIPT_EXTENSION, SERVER_CONSOLE_SCRIPT_EXTENSION -> event.defaultImports += STORY_PACKAGES + } + } + + /** Stories call what scripts declare, so they load after the scripts of this reload. */ + @SubscribeEvent + fun afterReloadScripts(event: ServerReloadScriptsEvent.Post) = StoryScripts.reload() + + @SubscribeEvent(-1) + fun registerCommands(event: RegisterCommandsEvent) { + val root = requireNotNull(event.dispatcher.root.getChild("hollowengine")) { + "The HollowEngine root command must be registered before addon commands" + } + DialogueCommands.register(root) + StoryCommands.register(root) + } + + private companion object { + const val DIALOGUE_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.scene.*" + val STORY_PACKAGES = listOf( + "ru.hollowhorizon.hollowengine.addons.narrative.story.*", + "ru.hollowhorizon.hollowengine.addons.narrative.npcs.*", + ) + + val RELOAD_IMPORTS = listOf(DIALOGUE_PACKAGE) + + /** Nodes start dialogues and play cutscenes for players. */ + val NODE_IMPORTS = listOf(DIALOGUE_PACKAGE) + listOf( + "playCutscene", + "playCutsceneAt", + "playCutsceneNear", + "playCutsceneHere", + "stopCutscene" + ).map { "ru.hollowhorizon.hollowengine.addons.narrative.cutscene.$it" } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueOverlays.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueOverlays.kt new file mode 100644 index 000000000..796934e94 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/DialogueOverlays.kt @@ -0,0 +1,200 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.client + +import androidx.compose.runtime.* +import net.minecraft.client.gui.screens.ChatScreen +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.addons.narrative.poll.ChoiceOverlay +import ru.hollowhorizon.hollowengine.addons.narrative.poll.ChoiceUiKeys +import ru.hollowhorizon.hollowengine.addons.narrative.poll.ChoiceView +import ru.hollowhorizon.hollowengine.addons.narrative.speech.SpeechOverlay +import ru.hollowhorizon.hollowengine.addons.narrative.speech.SpeechUiKeys +import ru.hollowhorizon.hollowengine.addons.narrative.speech.StoryText +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput +import ru.hollowhorizon.hollowengine.common.events.ClientOnly +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.client.ScreenEvent +import ru.hollowhorizon.hollowengine.common.ui.* +import ru.hollowhorizon.hollowengine.common.ui.hud.VanillaHudLayers +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser + +@ClientOnly +internal object DialogueOverlays { + const val STYLESHEET = "hollowengine-narrative:ui/styles/dialogue-overlay.hss" + var speechBounds by mutableStateOf(null) + + var screenOpen by mutableStateOf(false) + private set + + @SubscribeEvent + fun onScreenOpen(event: ScreenEvent.Open) { + screenOpen = true + } + + @SubscribeEvent + fun onScreenClose(event: ScreenEvent.Close) { + screenOpen = false + } + + private val anchors = listOf( + OverlayAnchor.HudLayer(VanillaHudLayers.CHAT, HudPlacement.AFTER), + OverlayAnchor.OnScreen(ScreenMatch.OfType(ChatScreen::class.java.name, subclasses = true), ScreenLevel.CONTENT), + ) + + val speech = UiOverlayDefinition( + id = SpeechOverlay.ID, + anchors = anchors, + autoShow = false, + input = OverlayInput.INTERACTIVE, + rebuildEveryFrame = false, + exitDuration = 520L, + content = { Speech() }, + ) + + val choice = UiOverlayDefinition( + id = ChoiceOverlay.ID, + anchors = anchors, + autoShow = false, + input = OverlayInput.INTERACTIVE, + rebuildEveryFrame = false, + exitDuration = 520L, + content = { Choice() }, + ) +} + +@Composable +private fun UiScope.Speech() { + val line = data[SpeechUiKeys.Line] + val text = remember(line.id) { StoryText.localized(line.text) } + val length = remember(text) { FormattedTextParser.parse(text).visibleLength } + var shown by remember(line.id) { mutableStateOf(if (line.charMillis <= 0) length else 0) } + val typed = shown >= length + LaunchedEffect(line.id) { + var start = -1L + while (shown < length) { + withFrameNanos { now -> + if (start < 0L) start = now + val typing = ((now - start) / 1_000_000L / line.charMillis).toInt() + shown = typing.coerceIn(shown, length) + } + } + } + DisposableEffect(Unit) { onDispose { DialogueOverlays.speechBounds = null } } + + fun skip() { + if (typed) send { putString(SpeechUiKeys.Action, SpeechUiKeys.Advance) } else shown = length + } + + val screenOpen = DialogueOverlays.screenOpen + var root = Modifier.style(DialogueOverlays.STYLESHEET).inputTransparent().focusScope() + if (!screenOpen) { + root = root.onKeyInput { input -> + if (line.text.isEmpty() || !isSkipKey(input)) return@onKeyInput + skip() + input.consume() + } + } + var box = Modifier.onPlaced { DialogueOverlays.speechBounds = it } + if (screenOpen) box = box.input(hoverable = true, clickable = true).onClick { skip() } + + Box(id = "speech-root", modifier = root) { + if (line.text.isEmpty()) return@Box + Column( + id = "speech", + tags = if (line.speaker.isEmpty()) listOf("narration") else emptyList(), + modifier = box, + ) { + if (line.speaker.isNotEmpty()) Text(StoryText.localized(line.speaker), id = "speech-name") + Text(id = "speech-text") { + FormattedTextSpans(text, shown, listOf("speech-pending")) + if (typed) Span(" Β»", id = "speech-next") + } + } + } +} + +private fun isSkipKey(input: UiKeyInput) = input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER + +@Composable +private fun UiScope.Choice() { + val choice = data[ChoiceUiKeys.Choice] + val count = choice.options.size + var highlighted by remember { mutableStateOf(0) } + val open = choice.decided < 0 + + fun vote(index: Int) { + if (!open || index !in 0 until count) return + highlighted = index + send { + putString(ChoiceUiKeys.Action, ChoiceUiKeys.Vote) + putInt(ChoiceUiKeys.Index, index) + } + } + + var root = Modifier.style(DialogueOverlays.STYLESHEET).focusScope() + if (!DialogueOverlays.screenOpen) { + root = root.input(clickable = true).onClick { vote(highlighted) }.onScroll { event -> + if (count > 0 && event.scrollY != 0f) highlighted = + (highlighted + if (event.scrollY > 0f) -1 else 1).mod(count) + event.consume() + }.onKeyInput { input -> + if (choiceKey(input, count, highlighted, ::vote) { + highlighted = it + }) input.consume() + } + } + + var size by remember { mutableStateOf(null) } + val speech = DialogueOverlays.speechBounds + val own = size + var modifier = Modifier.onPlaced { size = it } + if (speech != null && own != null) { + modifier = modifier.position((speech.x + speech.width - own.width).px, (speech.y - 3f - own.height).px) + } + + Box(id = "choice-root", modifier = root) { + Column(id = "choice", tags = if (speech != null) listOf("docked") else emptyList(), modifier = modifier) { + choice.options.forEachIndexed { index, text -> Option(index, text, choice, highlighted) { vote(index) } } + if (choice.voters > 1) Text("${choice.votes}/${choice.voters}", id = "choice-votes") + } + } +} + +@Composable +private fun Option(index: Int, text: String, choice: ChoiceView, highlighted: Int, vote: () -> Unit) { + val states = buildList { + if (index == highlighted && choice.decided < 0) add("highlighted") + if (index == choice.myVote) add("voted") + if (index == choice.decided) add("won") + if (choice.decided >= 0 && index != choice.decided) add("lost") + } + var modifier = Modifier.state(*states.toTypedArray()) + if (choice.decided < 0) modifier = modifier.input(hoverable = true, clickable = true).onClick { vote() } + Row(id = "choice-option-$index", tags = listOf("choice-option"), modifier = modifier) { + Text("${index + 1}", tags = listOf("choice-number")) + FormattedText(StoryText.localized(text), tags = listOf("choice-text")) + } +} + +private fun choiceKey( + input: UiKeyInput, + count: Int, + highlighted: Int, + vote: (Int) -> Unit, + move: (Int) -> Unit, +): Boolean { + if (count == 0) return false + val number = when (input.key) { + in GLFW.GLFW_KEY_1..GLFW.GLFW_KEY_9 -> input.key - GLFW.GLFW_KEY_1 + in GLFW.GLFW_KEY_KP_1..GLFW.GLFW_KEY_KP_9 -> input.key - GLFW.GLFW_KEY_KP_1 + else -> null + } + return when { + number != null -> (number < count).also { if (it) vote(number) } + input.key == GLFW.GLFW_KEY_UP -> true.also { move((highlighted - 1).mod(count)) } + input.key == GLFW.GLFW_KEY_DOWN -> true.also { move((highlighted + 1).mod(count)) } + input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER -> true.also { vote(highlighted) } + else -> false + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt new file mode 100644 index 000000000..a7f473aa9 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/NarrativeClient.kt @@ -0,0 +1,42 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.client + +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneCameraSystem +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor.CutsceneTimeline +import ru.hollowhorizon.hollowengine.addons.narrative.ide.graph.StoryGraphFilePreview +import ru.hollowhorizon.hollowengine.client.camera.CameraDirector +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeScriptTemplate +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdePreview +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeScriptTemplate +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.IdeTimelines +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensions +import ru.hollowhorizon.hollowengine.common.events.ClientOnly +import ru.hollowhorizon.hollowengine.common.ui.UiDefinitionRegistry + +/** + * What the addon adds to the client: the cutscene and dialogue cameras, the cutscene + * timeline and the editors of `.story` files in the IDE. + */ +@ClientOnly +internal object NarrativeClient { + /** A cutscene is the more specific instruction, so it holds the camera over a dialogue's own. */ + private const val CUTSCENE_CAMERA_PRIORITY = 10 + + fun register(extensions: HollowAddonExtensions) { + extensions.register(CameraDirector.sources, "cutscene", CutsceneCameraSystem, CUTSCENE_CAMERA_PRIORITY) + extensions.register(CameraDirector.sources, "story", StoryCameraSystem) + extensions.register(IdeTimelines.defaults, "cutscene", CutsceneTimeline) + extensions.register(UiDefinitionRegistry.builtInOverlays, "speech", DialogueOverlays.speech) + extensions.register(UiDefinitionRegistry.builtInOverlays, "choice", DialogueOverlays.choice) + extensions.registerIdePreview(StoryGraphFilePreview) + extensions.registerIdeScriptTemplate( + HollowIdeScriptTemplate.fromResource( + id = "dialogue", + label = "Dialogue", + extension = ".story", + defaultName = "dialogue", + resource = "dialogue.story", + owner = NarrativeClient::class.java, + ), + ) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/StoryScreenEffects.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt similarity index 89% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/StoryScreenEffects.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt index e4e4c34c7..2cf51c66f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/StoryScreenEffects.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/client/StoryScreenEffects.kt @@ -1,11 +1,13 @@ -package ru.hollowhorizon.hollowengine.client.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.client import net.minecraft.client.Minecraft import net.minecraft.client.renderer.RenderType -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose +import ru.hollowhorizon.hollowengine.client.camera.CameraPose +import ru.hollowhorizon.hollowengine.client.camera.CameraPoseSource import ru.hollowhorizon.hollowengine.common.events.ClientOnly import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.client.render.RenderHudEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderTickEvent import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f /** @@ -62,14 +64,14 @@ object StoryFadeOverlay { * A camera pose a story sets directly, interpolated from wherever the camera is now. */ @ClientOnly -object StoryCameraSystem { +object StoryCameraSystem : CameraPoseSource { private var from: CameraPose? = null private var target: CameraPose? = null private var startedAt = 0L private var durationMillis = 0L /** Where the story wants the camera, or null when it is leaving the camera alone. */ - val currentPose: CameraPose? + override val currentPose: CameraPose? get() { val destination = target ?: return null val origin = from ?: return destination @@ -107,10 +109,11 @@ object StoryCameraSystem { private var releaseAfter = 0L /** - * Called every client tick: finishes a release once its slide back is over, and hands the camera - * back the moment the player is no longer alive to look through it. + * Finishes a release once its slide back is over, and hands the camera back the moment the player + * is no longer alive to look through it. */ - fun update() { + @SubscribeEvent + fun onRenderTick(event: RenderTickEvent.Pre) { if (releaseAfter != 0L && System.currentTimeMillis() >= releaseAfter) clear() if (target == null) return val player = Minecraft.getInstance().player diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CameraCutscene.kt similarity index 97% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CameraCutscene.kt index f94701bcf..4c76cd8e6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CameraCutscene.kt @@ -1,20 +1,11 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene +import ru.hollowhorizon.hollowengine.client.camera.CameraPose import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import kotlin.math.max import kotlin.math.min -data class CameraPose( - val position: Vec3f = Vec3f.ZERO, - val rotation: Vec3f = Vec3f.ZERO, - val fov: Float = 70f, -) { - val pitch: Float get() = rotation.x - val yaw: Float get() = rotation.y - val roll: Float get() = rotation.z -} - /** The ids the camera rig is built from; a saved file binds back to its drivers through them. */ object CameraRig { const val CAMERA_ID = "camera" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player/CutsceneActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt similarity index 83% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player/CutsceneActions.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt index f45e031f9..96ddb4362 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/player/CutsceneActions.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneActions.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.scripting.story.functions.player +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import kotlinx.serialization.Serializable import net.minecraft.core.BlockPos @@ -6,11 +6,11 @@ import net.minecraft.server.level.ServerPlayer import net.minecraft.world.entity.Entity import net.minecraft.world.entity.player.Player import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneAnchor -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneAnchorKind -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneCameraSystem -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneStorage -import ru.hollowhorizon.hollowengine.common.network.HollowPacket +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchor +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneAnchorKind +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneCameraSystem +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneStorage +import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler fun Player.playCutscene(path: String, loop: Boolean = false) { @@ -99,7 +99,7 @@ class PlayCutscenePacket( val path: String, val loop: Boolean = false, val anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED, -) : HollowPacket { +) : HollowAddonPacket { override fun handle(player: Player) { val data = CutsceneStorage.load(path) CutsceneCameraSystem.play(data, loop, anchor) @@ -108,7 +108,7 @@ class PlayCutscenePacket( @HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) @Serializable -object StopCutscenePacket : HollowPacket { +object StopCutscenePacket : HollowAddonPacket { override fun handle(player: Player) { CutsceneCameraSystem.stop() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneAnchor.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneAnchor.kt similarity index 97% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneAnchor.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneAnchor.kt index c7ebd0973..de83a960d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneAnchor.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneAnchor.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import kotlinx.serialization.Serializable import net.minecraft.client.Minecraft diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCameraSystem.kt similarity index 67% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCameraSystem.kt index 60f1c7d89..f449ddca0 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCameraSystem.kt @@ -1,16 +1,20 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import net.minecraft.client.Minecraft import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.client.dialogue.StoryCameraSystem +import ru.hollowhorizon.hollowengine.client.camera.CameraPose +import ru.hollowhorizon.hollowengine.client.camera.CameraPoseSource import ru.hollowhorizon.hollowengine.common.events.ClientOnly import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent -import ru.hollowhorizon.hollowengine.common.events.client.render.RenderItemInHandEvent -import ru.hollowhorizon.hollowengine.common.events.client.render.RenderOverlayEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderTickEvent import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +/** + * The cutscene being played or previewed. It holds the camera above a dialogue's own camera, because it is + * the more specific instruction, and hides the hand and the screen overlays while it does. + */ @ClientOnly -object CutsceneCameraSystem { +object CutsceneCameraSystem : CameraPoseSource { private var controller: CutscenePlaybackController? = null private var isPreview = false private val environmentOverride = CutsceneEnvironmentOverride() @@ -18,20 +22,12 @@ object CutsceneCameraSystem { val activeController: CutscenePlaybackController? get() = controller - /** - * A pose held from outside any cutscene or story, by a tool that needs a frame seen from a given - * point. It wins over both while it is set and hides the hand like a cutscene does. - */ - @Volatile - var heldPose: CameraPose? = null - - /** - * The pose the camera should take. A held pose wins over everything; a cutscene being played wins - * over a story's own `@camera`, because it is the more specific instruction. The render bridge reads - * this one property, so all of them arrive through the same path. - */ - val currentPose: CameraPose? - get() = heldPose ?: controller?.currentPose ?: StoryCameraSystem.currentPose + override val currentPose: CameraPose? + get() = controller?.currentPose + + override val hidesHand: Boolean get() = true + + override val hidesOverlays: Boolean get() = true fun play(data: CutsceneData, loop: Boolean = false, anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED) { val playback = CutscenePlaybackController().also { @@ -69,9 +65,10 @@ object CutsceneCameraSystem { isPreview = false } - fun update(minecraft: Minecraft) { - StoryCameraSystem.update() + @SubscribeEvent + fun onRenderTick(event: RenderTickEvent.Pre) { val active = controller ?: return + val minecraft = event.minecraft val level = minecraft.level if (level == null || minecraft.player == null) { stop() @@ -96,14 +93,4 @@ object CutsceneCameraSystem { } private fun Vec3.toVec3f(): Vec3f = Vec3f(x.toFloat(), y.toFloat(), z.toFloat()) - - @SubscribeEvent - fun onRenderOverlays(event: RenderOverlayEvent.Pre) { - if (controller != null) event.isCanceled = true - } - - @SubscribeEvent - fun onRenderHand(event: RenderItemInHandEvent) { - if (controller != null || heldPose != null) event.isCanceled = true - } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneData.kt similarity index 97% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneData.kt index 5775fa1c2..571b387c2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneData.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import kotlinx.serialization.Serializable import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.HandleMode diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEditorSession.kt similarity index 93% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEditorSession.kt index 5729bb093..c22a08393 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEditorSession.kt @@ -1,7 +1,8 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import androidx.compose.runtime.mutableStateOf import net.minecraft.client.Minecraft +import ru.hollowhorizon.hollowengine.client.history.UndoLabel import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineEdits import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineKeys @@ -76,7 +77,7 @@ class CutsceneEditorSession { } fun moveOrigin(position: Vec3f, yaw: Float, keepWorld: Boolean) { - timeline.edit(if (keepWorld) TimelineEdits.REANCHOR_CUTSCENE else TimelineEdits.MOVE_CUTSCENE) { + timeline.edit(if (keepWorld) REANCHOR else MOVE) { if (keepWorld) playback.reanchor(position, yaw) else playback.origin = playback.origin.moved(position, yaw) } @@ -124,7 +125,9 @@ class CutsceneEditorSession { } } - object CutsceneEditorSessions { val default = CutsceneEditorSession() } + +private val MOVE = UndoLabel("${UndoLabel.LANG}.timeline.move_cutscene") +private val REANCHOR = UndoLabel("${UndoLabel.LANG}.timeline.reanchor_cutscene") diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEnvironment.kt similarity index 86% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEnvironment.kt index c8c5a5d27..97c708883 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneEnvironment.kt @@ -1,10 +1,9 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import net.minecraft.client.Minecraft import net.minecraft.client.multiplayer.ClientLevel import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* import kotlin.math.abs -import kotlin.math.floor import kotlin.math.max import kotlin.math.roundToLong @@ -24,21 +23,7 @@ object EnvironmentRig { const val TIME_OF_DAY_ID = "world.environment.time_of_day" const val WEATHER_ID = "world.environment.weather" - const val DAY_TICKS = 24_000L -} - -object TimeOfDayValueFormatter : ChannelValueFormatter { - override fun format(value: Float): String { - val dayTicks = EnvironmentRig.DAY_TICKS.toFloat() - val normalized = ((value % dayTicks) + dayTicks) % dayTicks - val minutesSinceSix = floor(normalized * MINUTES_PER_DAY / dayTicks).toInt() - val totalMinutes = (minutesSinceSix + SIX_AM_MINUTES) % MINUTES_PER_DAY - return "%02d:%02d".format(totalMinutes / MINUTES_PER_HOUR, totalMinutes % MINUTES_PER_HOUR) - } - - private const val MINUTES_PER_HOUR = 60 - private const val MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR - private const val SIX_AM_MINUTES = 6 * MINUTES_PER_HOUR + const val DAY_TICKS = TimeOfDayValueFormatter.DAY_TICKS } class TimeOfDayPropertyType : PropertyType { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrations.kt similarity index 99% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrations.kt index e7e05cf7b..5a5869ca9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrations.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneStorage.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneStorage.kt similarity index 98% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneStorage.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneStorage.kt index 88d405628..3d6ab5945 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneStorage.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneStorage.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import net.minecraft.nbt.CompoundTag import ru.hollowhorizon.hollowengine.common.files.DirectoryManager diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt new file mode 100644 index 000000000..4e5b088b3 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneLang.kt @@ -0,0 +1,31 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor + +/** Translation keys of the cutscene editor; the timeline's own are in `TimelineLang`. */ +internal object CutsceneLang { + private const val ROOT = "hollowengine.gui.ide.cutscene." + + const val SAVE_TITLE = ROOT + "dialog.save_title" + const val LOAD_TITLE = ROOT + "dialog.load_title" + const val NAME = ROOT + "dialog.name" + const val NO_CUTSCENES = ROOT + "dialog.no_cutscenes" + + const val PREVIEW = ROOT + "section.preview" + const val ORIGIN = ROOT + "section.origin" + + const val CAMERA_ON = ROOT + "preview.camera_on" + const val CAMERA_OFF = ROOT + "preview.camera_off" + const val PLAYING = ROOT + "preview.playing" + const val PAUSED = ROOT + "preview.paused" + const val CURRENT_TIME = ROOT + "preview.current_time" + const val DURATION = ROOT + "preview.duration" + + const val ORIGIN_X = ROOT + "origin.x" + const val ORIGIN_Y = ROOT + "origin.y" + const val ORIGIN_Z = ROOT + "origin.z" + const val ORIGIN_YAW = ROOT + "origin.yaw" + const val ORIGIN_MOVE_HERE = ROOT + "origin.move_here" + const val ORIGIN_REBASE = ROOT + "origin.rebase" + + const val WORLD = ROOT + "keyframe.world" + const val WORLD_ROTATION = ROOT + "keyframe.world_rotation" +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt new file mode 100644 index 000000000..847d54479 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneProperties.kt @@ -0,0 +1,90 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneEditorSession +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineColors +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineSelectionFields +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.ToolbarButton +import ru.hollowhorizon.hollowengine.client.ui.inspector.* +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f + +/** The cutscene's own fields, composed inside whichever panel the shared inspector is drawn in. */ +@Composable +internal fun CutsceneProperties(session: CutsceneEditorSession, refresh: () -> Unit) { + PreviewSection(session.timeline, refresh) + OriginSection(session, refresh) + TimelineSelectionFields(session.timeline, refresh) { WorldReadout(session) } +} + +@Composable +private fun PreviewSection(controller: TimelineController, refresh: () -> Unit) { + Section(CutsceneLang.PREVIEW.lang) { + Row( + modifier = Modifier.size(100.percent, 24.px) + .alignItems(vertical = UiAlign.CENTER) + .gap(8.px) + ) { + Pill( + if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_ON.lang + else CutsceneLang.CAMERA_OFF.lang, + controller.isCameraPreviewEnabled + ) { + controller.applyCameraPreviewEnabled(!controller.isCameraPreviewEnabled) + refresh() + } + Text( + if (controller.isPlaying) CutsceneLang.PLAYING.lang else CutsceneLang.PAUSED.lang, + modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted), + ) + } + Readonly(CutsceneLang.CURRENT_TIME.lang, "%.3f s".format(controller.currentTime).replace(',', '.')) + Readonly(CutsceneLang.DURATION.lang, "%.3f s".format(controller.workAreaEnd).replace(',', '.')) + } +} + +@Composable +private fun OriginSection(session: CutsceneEditorSession, refresh: () -> Unit) { + val origin = session.playback.origin + Section(CutsceneLang.ORIGIN.lang, id = "timeline-origin-section") { + NumberRow(CutsceneLang.ORIGIN_X.lang, origin.x) { next -> + session.moveOrigin(Vec3f(next, origin.y, origin.z), origin.yaw, keepWorld = false) + refresh() + } + NumberRow(CutsceneLang.ORIGIN_Y.lang, origin.y) { next -> + session.moveOrigin(Vec3f(origin.x, next, origin.z), origin.yaw, keepWorld = false) + refresh() + } + NumberRow(CutsceneLang.ORIGIN_Z.lang, origin.z) { next -> + session.moveOrigin(Vec3f(origin.x, origin.y, next), origin.yaw, keepWorld = false) + refresh() + } + NumberRow(CutsceneLang.ORIGIN_YAW.lang, origin.yaw, min = -360f, max = 360f, step = 1f) { next -> + session.moveOrigin(origin.position, next, keepWorld = false) + refresh() + } + Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(6.px)) { + ToolbarButton(CutsceneLang.ORIGIN_MOVE_HERE.lang, "timeline-origin-move") { + session.originToPlayer(keepWorld = false) + refresh() + } + ToolbarButton(CutsceneLang.ORIGIN_REBASE.lang, "timeline-origin-rebase") { + session.originToPlayer(keepWorld = true) + refresh() + } + } + } +} + + +@Composable +private fun WorldReadout(session: CutsceneEditorSession) { + val pose = session.playback.currentPose + Readonly(CutsceneLang.WORLD.lang, formatVec3(pose.position)) + Readonly(CutsceneLang.WORLD_ROTATION.lang, formatVec3(pose.rotation)) +} + +private fun formatVec3(vector: Vec3f): String = + "%.2f %.2f %.2f".format(vector.x, vector.y, vector.z).replace(',', '.') diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt new file mode 100644 index 000000000..e533105ec --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/editor/CutsceneTimelineDock.kt @@ -0,0 +1,197 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene.editor + +import androidx.compose.runtime.* +import kotlinx.coroutines.isActive +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneEditorSession +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneEditorSessions +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.CutsceneStorage +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.DefaultTimeline +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.HollowTimelineEditor +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.LocalTimelineRevision +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineColors +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineLang +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.ToolbarButton +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget +import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector +import ru.hollowhorizon.hollowengine.client.utils.lang + +private const val CutsceneIcon = "hollowengine:textures/gui/icons/film.svg" + +@Composable +internal fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean = true) { + session.uiRevision.value + var dialog by remember { mutableStateOf(CutsceneDialog.NONE) } + + LaunchedEffect(session) { + var lastNanos = -1L + while (isActive) { + withFrameNanos { now -> + val delta = + if (lastNanos < 0L) 0f else ((now - lastNanos) / 1_000_000_000.0).toFloat().coerceIn(0f, 0.1f) + lastNanos = now + if (session.timeline.isPlaying) session.update(delta) + if (session.timeline.curveAxis.advance(now)) session.invalidateUi() + } + } + } + + PublishInspector(source = "cutscene", key = cutsceneInspectorKey(session)) { + cutsceneInspectorTarget(session) + } + + CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) { + Box(id = "cutscene-timeline-dock", mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { + HollowTimelineEditor( + controller = session.timeline, + refresh = session::invalidateUi, + onKeyInput = session::onHollowUiKey, + keyboardActive = keyboardActive, + onCapture = { session.captureFrame(session.timeline.currentTime) }, + onSave = { dialog = CutsceneDialog.SAVE }, + onLoad = { dialog = CutsceneDialog.LOAD }, + ) + + when (dialog) { + CutsceneDialog.SAVE -> SaveCutsceneDialog(session) { dialog = CutsceneDialog.NONE } + CutsceneDialog.LOAD -> LoadCutsceneDialog(session) { dialog = CutsceneDialog.NONE } + CutsceneDialog.NONE -> {} + } + } + } +} + +private fun cutsceneInspectorTarget(session: CutsceneEditorSession) = InspectorTarget( + id = "cutscene-${cutsceneInspectorKey(session)}", + title = TimelineLang.PROPERTIES.lang, + icon = CutsceneIcon, +) { + CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) { + CutsceneProperties(session, refresh = session::invalidateUi) + } +} + +private fun cutsceneInspectorKey(session: CutsceneEditorSession): String { + val controller = session.timeline + val keyframe = controller.selectedKeyframes.firstOrNull() + ?: return if (controller.isWorkAreaSelected) "work-area" else "none" + return "key-${controller.curveOf(keyframe)?.spec?.name}-${keyframe.time}" +} + +private enum class CutsceneDialog { NONE, SAVE, LOAD } + +// --------------------------------------------------------------------------- +// Save / Load dialogs (lightweight overlays over the timeline dock) +// --------------------------------------------------------------------------- + +@Composable +private fun SaveCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) { + var name by remember { mutableStateOf(session.playback.toData().name) } + DialogFrame(CutsceneLang.SAVE_TITLE.lang, onClose) { + Text(CutsceneLang.NAME.lang, modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted)) + TextField( + value = name, + onChange = { name = it }, + modifier = Modifier.size(100.percent, 24.px) + .background(TimelineColors.Background) + .border(1.px, TimelineColors.Border, 3f) + .padding(6.px, 2.px) + .foreground(TimelineColors.Text) + .fontSize(11f), + ) + Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(8.px).align(horizontal = UiAlign.END)) { + ToolbarButton(TimelineLang.CANCEL.lang, "cutscene-save-cancel") { onClose() } + ToolbarButton(TimelineLang.SAVE.lang, "cutscene-save-confirm", primary = true) { + if (name.isNotBlank()) { + session.exportCutscene("", name.trim()) + onClose() + } + } + } + } +} + +@Composable +private fun LoadCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) { + val files = remember { CutsceneStorage.listFiles() } + DialogFrame(CutsceneLang.LOAD_TITLE.lang, onClose) { + if (files.isEmpty()) { + Text(CutsceneLang.NO_CUTSCENES.lang, modifier = Modifier.fontSize(11f).foreground(TimelineColors.Muted)) + } else { + Column( + modifier = Modifier.size(100.percent, UiLength.Auto) + .gap(2.px) + .scrollable(horizontal = false), + ) { + files.forEach { file -> + Box( + modifier = Modifier.size(100.percent, 24.px) + .background(TimelineColors.Background) + .border(1.px, TimelineColors.Border, 3f) + .cursor(UiCursorShape.HAND) + .onClick { + session.importCutscene(file) + onClose() + it.consume() + }, + ) { + Text( + file.removeSuffix(".${CutsceneStorage.EXTENSION}"), + modifier = Modifier.align(vertical = UiAlign.CENTER) + .margin(8.px, 0.px, 0.px, 0.px) + .fontSize(11f) + .foreground(TimelineColors.Text), + ) + } + } + } + } + Row(modifier = Modifier.size(100.percent, UiLength.Auto).align(horizontal = UiAlign.END)) { + ToolbarButton(TimelineLang.CLOSE.lang, "cutscene-load-close") { onClose() } + } + } +} + +@Composable +private fun DialogFrame(title: String, onClose: () -> Unit, content: HollowUiContent) { + val viewport = LocalUiViewport.current + Popup( + anchorBounds = viewport, + alignment = CenteredOnAnchor, + id = "cutscene-dialog-popup", + layer = 100, + modal = true, + onDismiss = onClose, + ) { + Column( + id = "cutscene-dialog", + modifier = Modifier.size(340.px, UiLength.Auto) + .maxSize(height = (viewport.height - 80f).coerceAtLeast(120f).px) + .background(TimelineColors.Panel) + .border(1.px, TimelineColors.Border, 6f) + .padding(14.px) + .gap(8.px) + .scrollable(horizontal = false), + ) { + Text(title, modifier = Modifier.fontSize(13f).foreground(TimelineColors.Text)) + content() + } + } +} + +private val CenteredOnAnchor = UiPopupAlignment( + anchorHorizontal = UiAlign.CENTER, + anchorVertical = UiAlign.CENTER, + popupHorizontal = UiAlign.CENTER, + popupVertical = UiAlign.CENTER, +) + +/** The cutscene being authored, shown in the timeline window while no editor has published its own timeline. */ +internal object CutsceneTimeline : DefaultTimeline { + override val controller: TimelineController + get() = CutsceneEditorSessions.default.timeline + + @Composable + override fun Content(keyboardActive: Boolean) = CutsceneTimelineDock(CutsceneEditorSessions.default, keyboardActive) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/SortedListSerializer.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/SortedListSerializer.kt new file mode 100644 index 000000000..cb5192a2f --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/SortedListSerializer.kt @@ -0,0 +1,35 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** Writes a list in the order of [comparator], so that the same content is always the same text. */ +class SortedListSerializer( + elementSerializer: KSerializer, + private val comparator: Comparator, +) : KSerializer> { + private val delegate = ListSerializer(elementSerializer) + override val descriptor: SerialDescriptor = delegate.descriptor + + override fun serialize(encoder: Encoder, value: List) = delegate.serialize(encoder, value.sortedWith(comparator)) + + override fun deserialize(decoder: Decoder): List = delegate.deserialize(decoder) +} + +object SortedVariablesSerializer : KSerializer> by SortedListSerializer( + StoryVariable.serializer(), + compareBy { it.name }, +) + +object SortedNodesSerializer : KSerializer> by SortedListSerializer( + StoryNode.serializer(), + compareBy { it.id }, +) + +object SortedLinksSerializer : KSerializer> by SortedListSerializer( + StoryLink.serializer(), + compareBy({ it.from }, { it.fromPin }, { it.to }, { it.toPin }), +) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModels.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModels.kt new file mode 100644 index 000000000..976539837 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModels.kt @@ -0,0 +1,51 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +sealed interface StoryEntityModel { + data class Path(val model: String) : StoryEntityModel + data class Npc(val id: String) : StoryEntityModel +} + +object StoryEntityModels { + fun of(graph: StoryGraph, signatureOf: (StoryNode.Call) -> StoryFunctionSignature?): Map { + val plain = graph.dissolved() + val nodes = plain.nodes.associateBy { it.id } + val writers = plain.nodes.filterIsInstance().groupBy { it.variable } + val result = HashMap() + plain.nodes.filterIsInstance().forEach { call -> + val signature = signatureOf(call) ?: return@forEach + if (signature.params.none { it.meaning == StoryParamMeaning.ANIMATION }) return@forEach + val target = signature.params.firstOrNull { it.receiver } ?: return@forEach + val sources = Sources(plain, nodes, writers, signatureOf).into(call.id, target.name) + sources.distinct().singleOrNull()?.let { result[call.id] = it } + } + return result + } + + private class Sources( + private val graph: StoryGraph, + private val nodes: Map, + private val writers: Map>, + private val signatureOf: (StoryNode.Call) -> StoryFunctionSignature?, + ) { + private val seen = HashSet() + + fun into(node: String, pin: String): List { + val link = graph.index.values[node to pin] ?: return emptyList() + return when (val from = nodes[link.from]) { + is StoryNode.Call -> listOfNotNull(made(from)) + is StoryNode.Expression -> writers[from.code.trim()].orEmpty().filter { seen.add(it.id) } + .flatMap { into(it.id, StoryPins.VALUE) } + + else -> emptyList() + } + } + + private fun made(call: StoryNode.Call): StoryEntityModel? { + val params = signatureOf(call)?.params ?: return null + fun written(meaning: StoryParamMeaning) = params.firstOrNull { it.meaning == meaning } + ?.let { param -> call.argument(param)?.let { StoryLiterals.literal(it, param.type) } } + return written(StoryParamMeaning.MODEL)?.let(StoryEntityModel::Path) + ?: written(StoryParamMeaning.NPC_ID)?.let(StoryEntityModel::Npc) + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctionSignature.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctionSignature.kt new file mode 100644 index 000000000..bfda910e3 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctionSignature.kt @@ -0,0 +1,91 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlin.reflect.KType + +/** What an options object such as `MoveOptions` a parameter belongs to: [parameter] of the function, built by [constructor]. */ +data class StoryParamGroup(val parameter: String, val constructor: String) + +enum class StoryParamMeaning { + /** A clip of the model of the entity the call is made on. */ + ANIMATION, + + /** The model of the entity the call gives back. */ + MODEL, + + /** The author's id of the NPC the call gives back. */ + NPC_ID, +} + +/** + * One parameter of a command as the editors fill it. + * + * @param type its Kotlin type, which says how a value typed into a field is written in Kotlin. + * @param optional whether the call can leave it out. + * @param suggestions the values it takes, when they are a known set; nothing checks against them. + * @param title what the editor calls it instead of [name], a lang key or plain text. + * @param description what it is for, a lang key or plain text. + * @param receiver whether it is what the function is called on. + * @param group the options object it is a field of. + * @param implicit the expression it takes when nobody fills it. + * @param widget what edits it instead of a plain field, such as `color`. + * @param assets the ends of the paths of the files it names, when it names one. + * @param meaning what its value names, when the editor can check that. + */ +data class StoryParam( + val name: String, + val type: KType? = null, + val optional: Boolean = false, + val suggestions: List = emptyList(), + val title: String? = null, + val description: String? = null, + val receiver: Boolean = false, + val group: StoryParamGroup? = null, + val implicit: String? = null, + val assets: List = emptyList(), + val widget: String? = null, + val meaning: StoryParamMeaning? = null, +) + +/** + * A command as the editors show it. + * + * @param name its id. + * @param title what the editor calls it, a lang key or plain text. + * @param description what it does, a lang key or plain text. + * @param icon a `namespace:textures/...` icon the editor shows beside it. + * @param category the heading the editor lists it under, a lang key or plain text. + * @param categoryColor the `#RRGGBB` nodes of the category take, or null to be given one. + * @param returns the type of what a call gives back, or null when it gives nothing. + * @param block whether a call takes the flow from [StoryPins.BODY] as its last argument, such as `temporary { }`. + * @param hidden whether stories that call it still work while nothing offers it for new ones. + */ +data class StoryFunctionSignature( + val name: String, + val function: String, + val params: List, + val title: String? = null, + val description: String? = null, + val icon: String? = null, + val category: String? = null, + val categoryColor: String? = null, + val returns: String? = null, + val block: Boolean = false, + val hidden: Boolean = false, +) { + /** A new call of this command. */ + fun newCall(id: String) = StoryNode.Call(id, action = name) +} + +/** The expression [param] has in this call, or null when it is left out. */ +fun StoryNode.Call.argument(param: StoryParam): String? = when (val group = param.group) { + null -> arguments[param.name] + else -> groups[group.parameter]?.arguments?.get(param.name) +} + +/** This call with [param] set to [value]; a blank one leaves it out. */ +fun StoryNode.Call.with(param: StoryParam, value: String): StoryNode.Call { + fun Map.set() = if (value.isBlank()) this - param.name else this + (param.name to value) + val group = param.group ?: return copy(arguments = arguments.set()) + val written = groups[group.parameter] ?: StoryGroup(group.constructor) + return copy(groups = groups + (group.parameter to written.copy(arguments = written.arguments.set()))) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctions.kt new file mode 100644 index 000000000..51b975513 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryFunctions.kt @@ -0,0 +1,69 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryHandle +import kotlin.reflect.typeOf + +/** How a call behaves when a restart interrupts the story. */ +enum class StoryCallKind { + /** Cannot suspend, so nothing can happen between it and the steps next to it. */ + INSTANT, + + /** An `@Action`: the stage compiler already keeps it from repeating once done. */ + ACTION, + + /** May suspend with nothing to mark how far it got, such as `say` waiting to be read. */ + SUSPENDING, +} + +/** What is known of the commands a story calls. A node names its command, and everything else about it comes from here. */ +interface StoryFunctions { + fun signature(call: StoryNode.Call): StoryFunctionSignature? + + fun kind(call: StoryNode.Call): StoryCallKind + + /** Knows the functions of the runtime alone; a call of anything else is treated as able to suspend. */ + object Builtin : StoryFunctions { + override fun signature(call: StoryNode.Call) = call.action?.let(StoryBuiltins::find) + + override fun kind(call: StoryNode.Call) = StoryCallKind.SUSPENDING + } +} + +/** [call] with what its [StoryFunctions.signature] says, which beats anything a file written by an older version holds. */ +fun StoryFunctions.resolved(call: StoryNode.Call): StoryNode.Call { + val signature = signature(call) ?: return call + return call.copy( + function = signature.function, + returns = signature.returns, + block = signature.block, + receiver = signature.params.firstOrNull { it.receiver }?.name, + ) +} + +/** The functions of the story runtime that steer a story rather than act on the world. */ +object StoryBuiltins { + private const val STORY_PACKAGE = "ru.hollowhorizon.hollowengine.addons.narrative.story" + private const val HANDLE = "$STORY_PACKAGE.StoryHandle" + private const val FLOW = "hollowengine.gui.ide.story.category.flow" + + val all: List = listOf( + builtin("checkpoint", param("name"), flag("restart")), + builtin("rewind", param("checkpoint", optional = true)), + builtin("temporary", param("until", optional = true)).copy(block = true), + builtin("async", param("name")).copy(returns = HANDLE, block = true), + builtin("join", handle()).copy(function = "join"), + builtin("cancel", handle()).copy(function = "cancel"), + builtin("scene", param("address")), + ) + + fun find(action: String): StoryFunctionSignature? = all.firstOrNull { it.name == action } + + private fun builtin(name: String, vararg params: StoryParam) = + StoryFunctionSignature(name, "$STORY_PACKAGE.$name", params.toList(), category = FLOW) + + private fun handle() = StoryParam("handle", typeOf>(), receiver = true) + + private fun param(name: String, optional: Boolean = false) = StoryParam(name, typeOf(), optional) + + private fun flag(name: String) = StoryParam(name, typeOf(), optional = true, suggestions = listOf("true", "false")) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraph.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraph.kt new file mode 100644 index 000000000..460280b7c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraph.kt @@ -0,0 +1,294 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator +import java.util.* + +/** + * A story as authors edit it: nodes and the links between them, which [StoryGraphCompiler] turns into a script. + * + * @param imports what the Kotlin of its nodes needs imported. + * @param layout where the graph editor shows each node; a node without a place is arranged when it is shown. + * @param groups the frames the graph editor keeps nodes in. + * @param collapsed the nodes the graph editor shows as a title bar. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StoryGraph( + @EncodeDefault val version: Int = VERSION, + val imports: List = emptyList(), + @Serializable(SortedVariablesSerializer::class) val variables: List = emptyList(), + @Serializable(SortedNodesSerializer::class) val nodes: List = emptyList(), + @Serializable(SortedLinksSerializer::class) val links: List = emptyList(), + val layout: Map = emptyMap(), + val groups: List = emptyList(), + val collapsed: Set = emptySet(), +) { + private val byId by lazy { nodes.associateBy { it.id } } + + /** The links by what they join. */ + val index: StoryLinkIndex by lazy { StoryLinkIndex(links) } + + fun node(id: String): StoryNode? = byId[id] + + /** What the story means, with nothing of how the editor shows it: two files of one revision run the same. */ + fun revision(): String = FORMAT.encodeToString( + serializer(), + dissolved().copy(layout = emptyMap(), groups = emptyList(), collapsed = emptySet()) + ) + + /** The graph with every reroute joined back up: what the links mean to a story. */ + fun dissolved(): StoryGraph { + val reroutes = nodes.filterIsInstance().mapTo(HashSet()) { it.id } + if (reroutes.isEmpty()) return this + + fun flowTarget(id: String): String? { + val seen = HashSet() + var at = id + while (at in reroutes) { + if (!seen.add(at)) return null + val from = at + at = index.flowOut(from).firstOrNull() ?: return null + } + return at + } + + fun valueSource(link: StoryLink): StoryLink? { + val seen = HashSet() + var at = link + while (at.from in reroutes) { + if (!seen.add(at.from)) return null + val into = at.from + at = index.valuesInto(into).firstOrNull() ?: return null + } + return at + } + + val joined = links.mapNotNull { link -> + when { + link.isFlow -> if (link.from in reroutes) null else flowTarget(link.to)?.let { link.copy(to = it) } + link.to in reroutes -> null + else -> valueSource(link)?.let { link.copy(from = it.from, fromPin = it.fromPin) } + } + } + return copy(nodes = nodes.filter { it.id !in reroutes }, links = joined) + } + + /** The text of the file: the same content is always the same text, so a change is a small diff. */ + fun encode(): String = + FORMAT.encodeToString(serializer(), copy(layout = layout.toSortedMap(), collapsed = collapsed.toSortedSet())) + + companion object { + const val VERSION = 1 + + private val FORMAT = Json { + ignoreUnknownKeys = true + encodeDefaults = false + prettyPrint = true + prettyPrintIndent = " " + } + + fun decode(text: String): StoryGraph = FORMAT.decodeFromString(serializer(), text) + + /** An id for a new node. */ + fun newId(): String = UUID.randomUUID().toString().replace("-", "").take(ID_LENGTH) + + private const val ID_LENGTH = 10 + } +} + +/** The links of a graph by what they join: going through a graph must not search all its links at every step. */ +class StoryLinkIndex(links: List) { + private val flowLinks = links.filter { it.isFlow } + private val valueLinks = links.filter { !it.isFlow } + private val outgoing = flowLinks.groupBy { it.from } + private val entering = flowLinks.groupBy { it.to } + private val reading = valueLinks.groupBy { it.to } + private val read = valueLinks.mapTo(HashSet()) { it.from to it.fromPin } + + /** Where flow goes from a pin of a node. */ + val flow: Map, String> = flowLinks.associate { (it.from to it.fromPin) to it.to } + + /** What a pin of a node reads. */ + val values: Map, StoryLink> = valueLinks.associateBy { it.to to it.toPin } + + fun flowOut(node: String): List = outgoing[node].orEmpty().map { it.to } + + fun flowIn(node: String): List = entering[node].orEmpty().map { it.from } + + fun valuesInto(node: String): List = reading[node].orEmpty() + + fun isRead(node: String, pin: String): Boolean = (node to pin) in read + + fun isInFlow(node: String): Boolean = node in outgoing || node in entering + + /** Whether several links lead to [node], which makes it a stage of its own. */ + fun isMerge(node: String): Boolean = flowIn(node).size > 1 +} + +@Serializable +data class StoryPoint(val x: Float, val y: Float) + +/** A frame of the graph editor around [nodes], or one node when [collapsed]; it means nothing to the story. */ +@Serializable +data class StoryNodeGroup( + val id: String, + val title: String, + val color: String = "gray", + val nodes: List = emptyList(), + val collapsed: Boolean = false, +) + +/** A variable of the story, saved with it. [initial] is a Kotlin expression; [type] is inferred from it when null. */ +@Serializable +data class StoryVariable(val name: String, val initial: String, val type: String? = null) + +/** + * A connection from a pin of one node to a pin of another. Flow enters a node at [StoryPins.IN]; any other target + * pin takes a value in place of the argument of that name. + */ +@Serializable +data class StoryLink(val from: String, val fromPin: String, val to: String, val toPin: String) { + val isFlow: Boolean get() = toPin == StoryPins.IN +} + +object StoryPins { + const val IN = "in" + const val NEXT = "next" + const val THEN = "then" + const val ELSE = "else" + const val BODY = "body" + const val VALUE = "value" + const val SPEAKER = "speaker" + const val TEXT = "text" + const val CONDITION = "condition" + const val ITEMS = "items" + const val ITEM = "item" + + /** The argument an extension function is called on, unless the node names another. */ + const val RECEIVER = "this" + + fun option(index: Int): String = "option$index" + + fun optionIndex(pin: String): Int? = pin.removePrefix("option").takeIf { pin.startsWith("option") }?.toIntOrNull() +} + +/** One answer of [StoryNode.Choose]. [condition], a Kotlin expression, offers it only when it holds. */ +@Serializable +data class StoryOption(val text: String, val condition: String? = null) + +/** An argument built by [constructor] from its own [arguments], such as `MoveOptions(speed = 1.2f)`. */ +@Serializable +data class StoryGroup(val constructor: String, val arguments: Map = emptyMap()) + +/** A step of a story. The [id] stays with the node for good: saves address what was generated from it. */ +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("kind") +@Serializable +sealed interface StoryNode { + val id: String + + /** Where a story can be started and flow can jump to; becomes the stage [name]. */ + @Serializable + @SerialName("start") + data class Start(override val id: String, val name: String) : StoryNode + + /** + * A line. [speaker] is a name, `{expression}` for what an expression gives, or empty for narration. In [text], + * `{name}` puts in the variable of that name, and `{player}` the name of the player. A link into + * [StoryPins.TEXT] takes the place of [text]. + */ + @Serializable + @SerialName("say") + data class Say(override val id: String, val speaker: String = "", val text: String = "") : StoryNode + + /** + * A call of the command [action]. [arguments] are Kotlin expressions by parameter name. With no flow into it the + * call is a value, placed where it is read. All that is known of a command comes from [StoryFunctions], so the other + * fields only describe a call of something no command stands for. + * + * @param function what is called, fully qualified or known to scripts. + * @param groups the arguments built from fields of their own. + * @param receiver the argument it is called on, when it is an extension. + * @param returns the type of a result that is read later. + * @param block whether it takes the flow from [StoryPins.BODY] as its last argument, such as `temporary { }`. + */ + @Serializable + @SerialName("call") + data class Call( + override val id: String, + val function: String = "", + val action: String? = null, + val arguments: Map = emptyMap(), + val groups: Map = emptyMap(), + val receiver: String? = null, + val returns: String? = null, + val block: Boolean = false, + ) : StoryNode + + /** Asks to choose one of [options]; each continues from its [StoryPins.option] pin. */ + @Serializable + @SerialName("choose") + data class Choose(override val id: String, val options: List) : StoryNode + + /** Continues from [StoryPins.THEN] when [condition] holds and from [StoryPins.ELSE] otherwise. */ + @Serializable + @SerialName("branch") + data class Branch(override val id: String, val condition: String = "") : StoryNode + + /** Sets the variable [variable] to [value]. */ + @Serializable + @SerialName("set") + data class Assign(override val id: String, val variable: String, val value: String = "") : StoryNode + + /** + * Kotlin that the graph does not look into. [inputs] are locals it can read, by name; [outputs] are the type of + * each local it assigns for other nodes to read. With no flow through it, it is an expression: its last line + * is the value of [StoryPins.VALUE], worked out where that value is read. + */ + @Serializable + @SerialName("code") + data class Code( + override val id: String, + val code: String, + val inputs: Map = emptyMap(), + val outputs: Map = emptyMap(), + ) : StoryNode + + /** A Kotlin expression other nodes read. */ + @Serializable + @SerialName("expression") + data class Expression(override val id: String, val code: String) : StoryNode + + /** + * A value worked out by the [StoryOperator] named [operator] from [arguments], Kotlin expressions by input name. + * + * @param count how many inputs it has when the operator lets the author choose. + * @param element the Kotlin type of the elements of a list, which its inputs have to be. + */ + @Serializable + @SerialName("pure") + data class Pure( + override val id: String, + val operator: String, + val arguments: Map = emptyMap(), + val count: Int? = null, + ) : StoryNode + + /** Runs the flow from [StoryPins.BODY] for each of [items], a Kotlin expression, giving it [StoryPins.ITEM]. */ + @Serializable + @SerialName("for-each") + data class Loop(override val id: String, val items: String = "") : StoryNode + + /** A point of the graph editor a link passes through, which the story does not notice. */ + @Serializable + @SerialName("reroute") + data class Reroute(override val id: String, val flow: Boolean) : StoryNode { + val input: String get() = if (flow) StoryPins.IN else StoryPins.VALUE + val output: String get() = if (flow) StoryPins.NEXT else StoryPins.VALUE + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphCompiler.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphCompiler.kt new file mode 100644 index 000000000..60647e670 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphCompiler.kt @@ -0,0 +1,569 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import net.minecraft.world.entity.Entity +import kotlin.reflect.KClass +import kotlin.reflect.full.isSubclassOf + +/** + * What is wrong with the node [node], or with the graph as a whole when it is null. A [warning] lets it run. + * + * @param key its name among the translations, which take [arguments]. + * @param message the same in English, for a server, which has no translations of an addon. + */ +data class StoryGraphProblem( + val node: String?, + val key: String, + val message: String, + val arguments: List = emptyList(), + val warning: Boolean = false, +) + +/** A generated script; [nodes] holds the node each of its lines came from. */ +data class GeneratedStory(val code: String, val nodes: List, val problems: List) { + /** The node behind the 1-based [line] of the script, for reporting what the Kotlin compiler says about it. */ + fun nodeAt(line: Int): String? = nodes.getOrNull(line - 1) +} + +/** + * Generates a script of stages from a [StoryGraph]. Flow runs through a stage of its own from every node several + * links lead to, named after that node: a loop is then a tail call, and a save keeps pointing at the same stage when + * nodes around it move. A stage that returned is not repeated after a restart, so steps that can be interrupted get + * one of their own and those in a row that cannot share one: a story goes on from the line it was at and `x = x + 1` + * never counts twice. + */ +class StoryGraphCompiler private constructor( + source: StoryGraph, + private val functions: StoryFunctions, + private val detached: Boolean = false, +) { + /** A value as generated Kotlin; [nullable] when it can be null by what it is rather than by a mistake. */ + private class Value(val code: String, val nullable: Boolean = false) { + val present: String get() = if (nullable) "($code)!!" else code + } + + private val graph = source.dissolved().let { plain -> + plain.copy(nodes = plain.nodes.map { if (it is StoryNode.Call) functions.resolved(it) else it }) + } + private val problems = ArrayList() + private val nodes = HashMap() + private val index = + StoryLinkIndex(graph.links.filter { link -> graph.node(link.from) != null && graph.node(link.to) != null }) + private val flow = index.flow + private val values = index.values + private val variables = graph.variables.associateBy { it.name } + + private val lines = ArrayList() + private val origins = ArrayList() + private val imports = (DEFAULT_IMPORTS + graph.imports).toSortedSet() + private val pending = ArrayDeque<() -> Unit>() + private val queued = HashSet() + private val evaluating = HashSet() + + /** How many blocks the flow is inside of: a block is one lambda, which steps cannot be split out of. */ + private var blocks = 0 + + private fun generate(): GeneratedStory { + index() + val body = render() + val header = imports.map { "import $it" } + graph.variables.map(::declaration) + outputDeclarations() + return GeneratedStory( + code = (header + body.first).joinToString("\n", postfix = "\n"), + nodes = header.map { null } + body.second, + problems = problems, + ) + } + + /** What each output worked out on the spot reads, in Kotlin that stands on its own outside of the flow. */ + private fun pinValues(): Map, String> { + graph.nodes.forEach { nodes[it.id] = it } + return graph.nodes.flatMap { node -> computedPins(node).map { node.id to it } } + .associateWith { (node, pin) -> value(StoryLink(node, pin, node, pin)).present } + } + + private fun computedPins(node: StoryNode): List = when (node) { + is StoryNode.Pure -> StoryOperators.find(node.operator)?.outputs?.map { it.name }.orEmpty() + is StoryNode.Loop -> listOf(StoryPins.ITEM) + is StoryNode.Expression -> listOf(StoryPins.VALUE) + is StoryNode.Code -> if (index.isInFlow(node.id)) node.outputs.keys.toList() else listOf(StoryPins.VALUE) + is StoryNode.Call -> if (node.returns != null) listOf(StoryPins.VALUE) else emptyList() + else -> emptyList() + } + + private fun index() { + graph.nodes.forEach { node -> + if (!IDENTIFIER_PART.matches(node.id)) problem(node.id, "bad_id", "A node id may only hold letters, digits and _") + if (nodes.put(node.id, node) != null) problem(node.id, "shared_id", "Two nodes share this id") + } + graph.links.filter { it.from !in nodes || it.to !in nodes } + .forEach { problem(null, "missing_node", "A link joins a missing node: $it", "$it") } + graph.links.filter { it.isFlow }.groupBy { it.from to it.fromPin }.filterValues { it.size > 1 } + .forEach { (pin, _) -> + problem(pin.first, "many_next", "Pin '${pin.second}' continues to several nodes", pin.second) + } + graph.links.filter { !it.isFlow }.groupBy { it.to to it.toPin }.filterValues { it.size > 1 } + .forEach { (pin, _) -> + problem(pin.first, "many_values", "Pin '${pin.second}' reads several values", pin.second) + } + graph.nodes.filterIsInstance().filter { it.function.isEmpty() }.forEach { call -> + problem(call.id, "no_command", "There is no command '${call.action}'", call.action.orEmpty()) + } + problems += StoryValueFlow(graph, index).problems() + } + + private fun render(): Pair, List> { + val starts = graph.nodes.filterIsInstance().sortedBy { it.id } + if (starts.map { it.name }.distinct().size != starts.size) problem(null, "shared_start", "Two starts share a name") + starts.forEach { start -> + if (!IDENTIFIER.matches(start.name)) problem(start.id, "bad_stage", "'${start.name}' is not a name of a stage", start.name) + stage(start.name, start.id) { chain(flow[start.id to StoryPins.NEXT], 1) } + } + while (pending.isNotEmpty()) pending.removeFirst()() + return lines to origins + } + + private fun stage(name: String, node: String, body: () -> Unit) { + emit("", null) + emit("@State", node) + emit("suspend fun $name() {", node) + body() + emit("}", node) + } + + /** Emits the flow from [start]. A node reached by several links is entered through its stage. */ + private fun chain(start: String?, depth: Int) { + val id = start ?: return + val node = nodes[id] ?: return + when { + node is StoryNode.Start -> emit("${node.name}()", id, depth) + index.isMerge(id) -> { + if (queued.add(id)) pending += { stage(stageName(id), id) { step(id, 1) } } + emit("${stageName(id)}()", id, depth) + } + + else -> step(id, depth) + } + } + + private fun step(id: String, depth: Int) { + when (val node = nodes.getValue(id)) { + is StoryNode.Start, is StoryNode.Reroute -> Unit + is StoryNode.Expression, is StoryNode.Pure -> problem(id, "value_in_flow", "A value has no place in the flow") + is StoryNode.Say -> { + boundary(id, depth) { emit(sayOf(node), id, it) } + chain(flow[id to StoryPins.NEXT], depth) + } + + is StoryNode.Call -> { + when { + isInstant(node) -> return instantRun(node, depth) + functions.kind(node) == StoryCallKind.ACTION -> call(node, depth) + else -> boundary(id, depth) { call(node, it) } + } + chain(flow[id to StoryPins.NEXT], depth) + } + + is StoryNode.Assign -> { + if (isInstant(node)) return instantRun(node, depth) + boundary(id, depth) { instant(node, it) } + chain(flow[id to StoryPins.NEXT], depth) + } + + is StoryNode.Loop -> { + boundary(id, depth) { at -> loop(node, at) } + chain(flow[id to StoryPins.NEXT], depth) + } + + is StoryNode.Code -> { + boundary(id, depth) { code(node, it) } + chain(flow[id to StoryPins.NEXT], depth) + } + + is StoryNode.Branch -> { + emit("if (${argument(id, StoryPins.CONDITION, node.condition)}) {", id, depth) + chain(flow[id to StoryPins.THEN], depth + 1) + val otherwise = flow[id to StoryPins.ELSE] + if (otherwise != null) { + emit("} else {", id, depth) + chain(otherwise, depth + 1) + } + emit("}", id, depth) + } + + is StoryNode.Choose -> { + emit("when (${chooseOf(node)}) {", id, depth) + node.options.indices.forEach { index -> + emit("$index -> {", id, depth + 1) + chain(flow[id to StoryPins.option(index)], depth + 2) + emit("}", id, depth + 1) + } + emit("}", id, depth) + } + } + } + + /** + * Puts what [body] emits in a stage of its own and calls it; inside a block it stays in place. [body] gets the + * depth it is emitted at. + */ + private fun boundary(node: String, depth: Int, body: (Int) -> Unit) { + if (blocks > 0) return body(depth) + val name = "step_$node" + emit("$name()", node, depth) + pending += { stage(name, node) { body(1) } } + } + + private fun isInstant(node: StoryNode): Boolean = when (node) { + is StoryNode.Assign -> !readsSuspending(node.id) + is StoryNode.Call -> !node.block && functions.kind(node) == StoryCallKind.INSTANT && !readsSuspending(node.id) + else -> false + } + + /** Whether working out what [id] reads calls something that can suspend, which a call with no flow does where it is read. */ + private fun readsSuspending(id: String): Boolean { + val seen = hashSetOf(id) + val queue = ArrayDeque(index.valuesInto(id)) + while (queue.isNotEmpty()) { + val source = queue.removeFirst().from + if (index.isInFlow(source) || !seen.add(source)) continue + val node = nodes[source] + if (node is StoryNode.Code) return true + if (node is StoryNode.Call && (node.block || functions.kind(node) != StoryCallKind.INSTANT)) return true + queue += index.valuesInto(source) + } + return false + } + + /** Puts [first] and the instant steps right after it in one stage: nothing can interrupt it halfway. */ + private fun instantRun(first: StoryNode, depth: Int) { + val run = arrayListOf(first) + var next = flow[first.id to StoryPins.NEXT] + while (true) { + val node = next?.let(nodes::get) ?: break + if (!isInstant(node) || index.isMerge(node.id)) break + run += node + next = flow[node.id to StoryPins.NEXT] + } + boundary(first.id, depth) { at -> run.forEach { instant(it, at) } } + chain(next, depth) + } + + private fun instant(node: StoryNode, depth: Int) { + if (node is StoryNode.Call) return call(node, depth) + node as StoryNode.Assign + emit("${node.variable} = ${assigned(node)}", node.id, depth) + } + + /** What the variable of [node] is set to. */ + private fun assigned(node: StoryNode.Assign): String { + if (node.variable !in variables) problem(node.id, "no_variable", "There is no variable '${node.variable}'", node.variable) + val type = variables[node.variable]?.type + val empty = isNullable(type) + val value = argument(node.id, StoryPins.VALUE, node.value, empty) + return if ((node.id to StoryPins.VALUE) in values && !empty) coerced(value, type) else value + } + + /** The body of a loop is one lambda to the stages around it, so its steps stay in place. */ + private fun loop(node: StoryNode.Loop, depth: Int) { + emit("for (${loopName(node.id)} in ${argument(node.id, StoryPins.ITEMS, node.items)}) {", node.id, depth) + blocks++ + chain(flow[node.id to StoryPins.BODY], depth + 1) + blocks-- + emit("}", node.id, depth) + } + + /** Whether the Kotlin [code] is a variable that can be empty. */ + private fun readsEmpty(code: String) = isNullable(variables[code.trim()]?.type) + + private fun isNullable(type: String?) = type?.endsWith("?") == true + + /** [value] converted to [type], since a linked number or text is whatever the node before it gives. */ + private fun coerced(value: String, type: String?): String = + when (type?.removeSuffix("?")?.substringAfterLast('.')) { + "Int" -> "($value).toInt()" + "Long" -> "($value).toLong()" + "Short" -> "($value).toShort()" + "Byte" -> "($value).toByte()" + "Float" -> "($value).toFloat()" + "Double" -> "($value).toDouble()" + "String" -> "($value).toString()" + else -> value + } + + private fun call(node: StoryNode.Call, depth: Int) { + val call = callOf(node) + val assigned = if (index.isRead(node.id, StoryPins.VALUE)) { + if (node.returns == null) problem(node.id, "needs_return", "'${node.function}' is read later, so it needs its return type", node.function) + "${outputName(node.id, StoryPins.VALUE)} = $call" + } else call + if (!node.block) return emit(assigned, node.id, depth) + emit("$assigned {", node.id, depth) + blocks++ + chain(flow[node.id to StoryPins.BODY], depth + 1) + blocks-- + emit("}", node.id, depth) + } + + private fun code(node: StoryNode.Code, depth: Int) { + node.inputs.forEach { (name, value) -> emit("val $name = ${argument(node.id, name, value)}", node.id, depth) } + node.outputs.forEach { (name, type) -> emit("var $name: ${nullable(type)} = null", node.id, depth) } + node.code.trimIndent().lines().forEach { emit(it, node.id, depth) } + node.outputs.keys.filter { index.isRead(node.id, it) } + .forEach { emit("${outputName(node.id, it)} = $it", node.id, depth) } + } + + private fun sayOf(node: StoryNode.Say): String { + val link = values[node.id to StoryPins.SPEAKER] + val written = node.speaker.trim() + val speaker = when { + link != null -> value(link).present + written.isEmpty() -> "null as String?" + written.startsWith("{") && written.endsWith("}") -> Value( + written.substring(1, written.length - 1), + readsEmpty(written.trim('{', '}')) + ).present + + else -> template(written) + } + val text = + values[node.id to StoryPins.TEXT]?.let { coerced(value(it).present, "String") } ?: template(node.text) + return "say($speaker, $text)" + } + + private fun chooseOf(node: StoryNode.Choose): String { + if (node.options.none { it.condition != null }) { + return "choose(listOf(${node.options.joinToString { template(it.text) }}))" + } + return "chooseIf(listOf(${ + node.options.joinToString { "${template(it.text)} to (${it.condition?.ifBlank { null } ?: "true"})" } + }))" + } + + private fun callOf(node: StoryNode.Call): String { + val name = node.function.substringAfterLast('.') + if ('.' in node.function) imports += node.function + val receiverPin = node.receiver ?: StoryPins.RECEIVER + val parameters = (node.arguments.keys + values.keys.filter { it.first == node.id } + .map { it.second }).filter { it != receiverPin }.distinct() + val direct = parameters.map { "$it = ${parameter(node, it)}" } + val groups = node.groups.filterValues { it.arguments.isNotEmpty() }.map { (parameter, group) -> + val fields = group.arguments.entries.joinToString { (field, value) -> "$field = $value" } + "$parameter = ${group.constructor}($fields)" + } + val arguments = (direct + groups).joinToString() + val given = receiverPin in node.arguments || (node.id to receiverPin) in values + val implicit = functions.signature(node)?.params?.firstOrNull { it.receiver }?.implicit + val receiver = if (given) parameter(node, receiverPin) else implicit + return if (receiver == null) "$name($arguments)" else "($receiver).$name($arguments)" + } + + private fun parameter(node: StoryNode.Call, name: String): String { + val type = functions.signature(node)?.params?.firstOrNull { it.name == name }?.type + val empty = type?.isMarkedNullable != false + val value = argument(node.id, name, node.arguments[name].orEmpty(), empty) + if ((node.id to name) !in values) return value + val kind = type?.classifier as? KClass<*> + if (kind != null && kind != Entity::class && kind.isSubclassOf(Entity::class)) { + return "($value as ${kind.qualifiedName}${if (empty) "?" else ""})" + } + return if (empty) value else coerced(value, kind?.simpleName) + } + + /** + * What [pin] of [node] gets: what is linked to it, or the Kotlin [written] into it. + * + * @param empty whether it takes null. When it does not, a value that can be null is asserted to be there. + */ + private fun argument(node: String, pin: String, written: String, empty: Boolean = false): String { + val link = values[node to pin] + if (link != null) return value(link).let { if (empty) it.code else it.present } + if (written.isBlank()) problem(node, "no_value", "Pin '$pin' has no value", pin) + return if (!empty && readsEmpty(written)) Value(written, true).present else written + } + + private fun value(link: StoryLink): Value { + val id = link.from + if (!evaluating.add(id)) { + problem(id, "own_value", "This node reads its own value") + return Value("error(\"cycle\")") + } + try { + return when (val node = nodes.getValue(id)) { + is StoryNode.Expression -> Value("(${node.code})", readsEmpty(node.code)) + is StoryNode.Pure -> Value(pure(node, link.fromPin)) + is StoryNode.Loop -> Value( + if (detached) "(${ + argument( + id, + StoryPins.ITEMS, + node.items + ) + }).iterator().next()" else loopName(id) + ) + + is StoryNode.Assign -> { + val empty = isNullable(variables[node.variable]?.type) + Value( + if (index.isInFlow(id)) node.variable else "(${assigned(node)}).also { ${node.variable} = it }", + empty + ) + } + + is StoryNode.Call -> { + val empty = isNullable(node.returns) + if (index.isInFlow(id)) output(id, StoryPins.VALUE, node.returns) else Value(callOf(node), empty) + } + + is StoryNode.Code -> when { + link.fromPin != StoryPins.VALUE -> { + if (link.fromPin !in node.outputs) problem(id, "no_output", "There is no output '${link.fromPin}'", link.fromPin) + output(id, link.fromPin, node.outputs[link.fromPin]) + } + + index.isInFlow(id) -> { + problem(id, "code_outputs", "Code in the flow gives values through its outputs") + Value("null") + } + + else -> Value(expressionOf(node)) + } + + else -> { + problem(id, "gives_nothing", "This node gives no value") + Value("error(\"no value\")") + } + } + } finally { + evaluating -= id + } + } + + /** The Kotlin of [node] as a value: its inputs are locals of a block of their own. */ + private fun expressionOf(node: StoryNode.Code): String { + val inputs = + node.inputs.map { (name, value) -> "val $name = ${argument(node.id, name, value)}\n" }.joinToString("") + return "run {\n$inputs${node.code.trimIndent()}\n}" + } + + /** What the flow left in the saved output [pin] of [node]: null there only means something if [type] can be null. */ + private fun output(node: String, pin: String, type: String?) = when { + detached -> Value("(null as ${nullable(type ?: "Any")})", true) + else -> Value(outputName(node, pin), true) + }.let { if (isNullable(type)) it else Value(it.present) } + + /** The output [pin] of [node]: its template with what each input reads put in. */ + private fun pure(node: StoryNode.Pure, pin: String): String { + val operator = StoryOperators.find(node.operator) + val output = operator?.outputs?.firstOrNull { it.name == pin } + if (output == null) { + problem(node.id, "no_operator_value", "There is no value '$pin' of '${node.operator}'", pin, node.operator) + return "error(\"no value\")" + } + val inputs = StoryOperators.inputsOf(node) + val given = + inputs.filter { it.default.isNotEmpty() || (node.id to it.name) in values || !node.arguments[it.name].isNullOrBlank() } + if (given.isEmpty() && output.empty != null) return output.empty + val template = output.template.replace("$*", given.joinToString { "$" + it.name }) + return OPERATOR_INPUT.replace(template) { match -> + val name = match.groupValues[1] + val input = inputs.first { it.name == name } + "(${argument(node.id, name, node.arguments[name] ?: input.default, input.type == StoryPinType.ANY)})" + } + } + + /** [text] as a Kotlin string that puts in the variables it names; anything else in braces stays as it is. */ + private fun template(text: String): String { + val parts = StringBuilder("\"") + var from = 0 + NAME_IN_BRACES.findAll(text).forEach { match -> + val value = when (val name = match.groupValues[1]) { + in variables.keys -> name + PLAYER -> "playerName()" + else -> return@forEach + } + parts.append(escape(text.substring(from, match.range.first))).append("\${").append(value).append('}') + from = match.range.last + 1 + } + return parts.append(escape(text.substring(from))).append('"').toString() + } + + private fun declaration(variable: StoryVariable): String { + if (!IDENTIFIER.matches(variable.name)) problem(null, "bad_variable", "'${variable.name}' is not a name of a variable", variable.name) + val type = variable.type?.let { "<$it>" }.orEmpty() + val initial = variable.initial.ifBlank { if (isNullable(variable.type)) "null" else "" } + if (initial.isEmpty()) problem(null, "needs_start", "'${variable.name}' needs a value to start with", variable.name) + return "var ${variable.name} by saved$type($initial)" + } + + /** Results of the flow live in saved variables, because any of them may be read from another stage. */ + private fun outputDeclarations(): List = graph.nodes.sortedBy { it.id }.flatMap { node -> + val outputs = when (node) { + is StoryNode.Call -> if (index.isInFlow(node.id)) mapOf(StoryPins.VALUE to node.returns) else emptyMap() + is StoryNode.Code -> node.outputs + else -> emptyMap() + } + outputs.filter { index.isRead(node.id, it.key) }.map { (pin, type) -> + "var ${outputName(node.id, pin)} by saved<${nullable(type ?: "Any")}>(null)" + } + } + + private fun nullable(type: String) = if (type.endsWith("?")) type else "$type?" + + private fun stageName(node: String) = "block_$node" + + private fun outputName(node: String, pin: String) = "out_${node}_$pin" + + private fun loopName(node: String) = "item_$node" + + private fun emit(line: String, node: String?, depth: Int = 0) { + lines += if (line.isEmpty()) "" else " ".repeat(depth) + line + origins += node + } + + private fun problem(node: String?, key: String, message: String, vararg arguments: String) { + problems += StoryGraphProblem(node, key, message, arguments.toList()) + } + + companion object { + private const val PLAYER = "player" + private const val NARRATIVE = "ru.hollowhorizon.hollowengine.addons.narrative" + private val DEFAULT_IMPORTS = listOf( + "$NARRATIVE.story.*", + "$NARRATIVE.npcs.*", + "$NARRATIVE.speech.*", + "$NARRATIVE.poll.*", + "$NARRATIVE.scene.commands.*", + "net.minecraft.server.level.ServerPlayer", + "net.minecraft.world.entity.Entity", + "net.minecraft.world.phys.Vec3", + "ru.hollowhorizon.hollowengine.common.entities.NpcEntity", + ) + private val IDENTIFIER_PART = Regex("[\\p{L}\\p{N}_]+") + private val IDENTIFIER = Regex("[\\p{L}_][\\p{L}\\p{N}_]*") + private val NAME_IN_BRACES = Regex("\\{([\\p{L}_][\\p{L}\\p{N}_]*)}") + private val OPERATOR_INPUT = Regex("\\$(\\w+)") + + fun compile(graph: StoryGraph, functions: StoryFunctions = StoryFunctions.Builtin): GeneratedStory = + StoryGraphCompiler(graph, functions).generate() + + /** What the outputs of [graph] worked out on the spot read, by node and pin, for telling their types. */ + fun pins(graph: StoryGraph, functions: StoryFunctions): Map, String> = + StoryGraphCompiler(graph, functions, detached = true).pinValues() + + /** [text] as a Kotlin string literal. */ + fun literal(text: String): String = "\"${escape(text)}\"" + + private fun escape(text: String): String = buildString { + text.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '$' -> append("\\$") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(char) + } + } + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphEdits.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphEdits.kt new file mode 100644 index 000000000..486d9ed73 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphEdits.kt @@ -0,0 +1,198 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +/** + * The changes editors make to a story. Each returns the graph with the change, or the same one if it cannot be made. + */ +object StoryGraphEdits { + private val INSERT = Regex("""\{([^{}]*)\}""") + + /** The story with the variable [old] called [name], in everything that reads or writes it. */ + fun StoryGraph.renameVariable(old: String, name: String): StoryGraph { + fun code(text: String) = StoryKotlin.renamed(text, old, name) + fun line(text: String) = INSERT.replace(text) { "{${code(it.groupValues[1])}}" } + fun Map.renamed() = mapValues { code(it.value) } + return copy( + variables = variables.map { + it.copy( + name = if (it.name == old) name else it.name, + initial = code(it.initial) + ) + }, + nodes = nodes.map { node -> + when (node) { + is StoryNode.Say -> node.copy(speaker = line(node.speaker), text = line(node.text)) + is StoryNode.Call -> node.copy( + arguments = node.arguments.renamed(), + groups = node.groups.mapValues { (_, group) -> group.copy(arguments = group.arguments.renamed()) }, + ) + + is StoryNode.Choose -> node.copy( + options = node.options.map { + it.copy( + text = line(it.text), + condition = it.condition?.let(::code) + ) + }, + ) + + is StoryNode.Branch -> node.copy(condition = code(node.condition)) + is StoryNode.Assign -> node.copy( + variable = if (node.variable == old) name else node.variable, + value = code(node.value) + ) + + is StoryNode.Code -> node.copy(code = code(node.code), inputs = node.inputs.renamed()) + is StoryNode.Expression -> node.copy(code = code(node.code)) + is StoryNode.Pure -> node.copy(arguments = node.arguments.renamed()) + is StoryNode.Loop -> node.copy(items = code(node.items)) + is StoryNode.Start, is StoryNode.Reroute -> node + } + }, + ) + } + + /** The nodes [ids] gone with their links; a link through a reroute among them joins back up. */ + fun StoryGraph.removeNodes(ids: Set): StoryGraph = + ids.mapNotNull { node(it) as? StoryNode.Reroute }.fold(this) { graph, reroute -> graph.bridge(reroute) } + .without(ids) + + private fun StoryGraph.bridge(reroute: StoryNode.Reroute): StoryGraph { + val into = links.filter { it.to == reroute.id } + val out = links.filter { it.from == reroute.id } + val joined = into.flatMap { before -> out.map { after -> before.copy(to = after.to, toPin = after.toPin) } } + return copy(links = links - (into + out).toSet() + joined) + } + + fun StoryGraph.startNames(): MutableSet = nodes.filterIsInstance().mapTo(HashSet()) { it.name } + + /** A name for a start that none of [taken] has: two starts of one name would be one stage. */ + fun freeStartName(taken: Set): String = + generateSequence(1) { it + 1 }.map { "scene$it" }.first { it !in taken } + + /** + * Copies of [ids], [offset] away, linked as they were among themselves; a value they read they keep reading. They + * stay collapsed if they were, and a start gets a name of its own. + */ + fun StoryGraph.copyNodes(ids: Set, offset: StoryPoint): Pair> { + val fresh = ids.associateWith { StoryGraph.newId() } + val taken = startNames() + val copies = fresh.mapNotNull { (old, new) -> node(old)?.withId(new) }.map { copy -> + if (copy is StoryNode.Start) copy.copy(name = freeStartName(taken).also(taken::add)) else copy + } + val kept = links.filter { it.to in ids && (it.from in ids || !it.isFlow) } + .map { it.copy(from = fresh[it.from] ?: it.from, to = fresh.getValue(it.to)) } + val places = fresh.mapNotNull { (old, new) -> + layout[old]?.let { new to StoryPoint(it.x + offset.x, it.y + offset.y) } + } + val folded = fresh.filterKeys { it in collapsed }.values + return copy( + nodes = nodes + copies, + links = links + kept, + layout = layout + places, + collapsed = collapsed + folded, + ) to fresh + } + + /** A new group of [ids], taken out of the groups they were in. */ + fun StoryGraph.withGroup(ids: Set, title: String): Pair { + val id = StoryGraph.newId() + val out = withMembership(ids, null) + return out.copy(groups = out.groups + StoryNodeGroup(id, title, nodes = ids.toList())) to id + } + + /** [ids] in [group], or in no group when it is null; a group left empty is gone. */ + fun StoryGraph.withMembership(ids: Set, group: String?): StoryGraph = copy( + groups = groups.map { each -> + val kept = each.nodes.filter { it !in ids } + each.copy(nodes = if (each.id == group) kept + ids else kept) + }.filter { it.nodes.isNotEmpty() }, + ) + + fun StoryGraph.withGroupChanged(id: String, change: (StoryNodeGroup) -> StoryNodeGroup): StoryGraph = + copy(groups = groups.map { if (it.id == id) change(it) else it }) + + /** The group [id] gone; its nodes stay. */ + fun StoryGraph.withoutGroup(id: String): StoryGraph = copy(groups = groups.filter { it.id != id }) + + /** The link out of [pin] of [from] gone: a jump taken out of the story. */ + fun StoryGraph.unlink(from: String, pin: String): StoryGraph = + copy(links = links.filterNot { it.isFlow && it.from == from && it.fromPin == pin }) + + /** Flow from [pin] of [from] goes to [to], instead of wherever it went. */ + fun StoryGraph.link(from: String, pin: String, to: String): StoryGraph = + unlink(from, pin).let { it.copy(links = it.links + StoryLink(from, pin, to, StoryPins.IN)) } + + /** [pin] of [to] reads the output [fromPin] of [from], instead of whatever it read. */ + fun StoryGraph.linkValue(from: String, fromPin: String, to: String, pin: String): StoryGraph = + copy(links = links.filterNot { !it.isFlow && it.to == to && it.toPin == pin } + StoryLink( + from, + fromPin, + to, + pin + )) + + /** [pin] of [node] no longer reads a value, and a call that only gave it that value is gone. */ + fun StoryGraph.unlinkValue(node: String, pin: String): StoryGraph { + val link = links.firstOrNull { !it.isFlow && it.to == node && it.toPin == pin } ?: return this + val kept = copy(links = links - link) + val source = kept.node(link.from) + val unused = kept.links.none { it.from == link.from || it.to == link.from } + return if (unused && source !is StoryNode.Code) kept.without(setOf(link.from)) else kept + } + + /** The node [id] as [change] leaves it. */ + fun StoryGraph.edit(id: String, change: (StoryNode) -> StoryNode): StoryGraph = + copy(nodes = nodes.map { if (it.id == id) change(it) else it }) + + /** The option [index] of the menu [id] gone with its link; later options keep theirs. */ + fun StoryGraph.removeOption(id: String, index: Int): StoryGraph { + val menu = node(id) as? StoryNode.Choose ?: return this + val pin = StoryPins.option(index) + val shifted = links.filterNot { it.from == id && it.fromPin == pin }.map { link -> + val at = if (link.from == id) StoryPins.optionIndex(link.fromPin) else null + if (at != null && at > index) link.copy(fromPin = StoryPins.option(at - 1)) else link + } + return copy(links = shifted).edit(id) { menu.copy(options = menu.options.filterIndexed { at, _ -> at != index }) } + } + + /** One more input on the variadic operator of [id]. */ + fun StoryGraph.addInput(id: String): StoryGraph = edit(id) { node -> + node as StoryNode.Pure + node.copy(count = StoryOperators.inputsOf(node).size + 1) + } + + /** The node [id] without its input at [index]; those after it move up with what they hold and read. */ + fun StoryGraph.removeInput(id: String, index: Int): StoryGraph { + val node = node(id) as? StoryNode.Pure ?: return this + val size = StoryOperators.inputsOf(node).size + val moved = + ((index + 1) until size).associate { StoryOperators.inputName(it) to StoryOperators.inputName(it - 1) } + val gone = StoryOperators.inputName(index) + val shifted = links.filterNot { it.to == id && it.toPin == gone }.map { link -> + if (link.to == id) link.copy(toPin = moved[link.toPin] ?: link.toPin) else link + } + val held = (node.arguments - gone).mapKeys { (name, _) -> moved[name] ?: name } + return copy(links = shifted).edit(id) { node.copy(arguments = held, count = size - 1) } + } + + private fun StoryGraph.without(ids: Set): StoryGraph = copy( + nodes = nodes.filterNot { it.id in ids }, + links = links.filterNot { it.from in ids || it.to in ids }, + layout = layout - ids, + collapsed = collapsed - ids, + ).withMembership(ids, null) + + private fun StoryNode.withId(id: String): StoryNode = when (this) { + is StoryNode.Start -> copy(id = id) + is StoryNode.Say -> copy(id = id) + is StoryNode.Call -> copy(id = id) + is StoryNode.Choose -> copy(id = id) + is StoryNode.Branch -> copy(id = id) + is StoryNode.Assign -> copy(id = id) + is StoryNode.Code -> copy(id = id) + is StoryNode.Expression -> copy(id = id) + is StoryNode.Pure -> copy(id = id) + is StoryNode.Loop -> copy(id = id) + is StoryNode.Reroute -> copy(id = id) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryKotlin.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryKotlin.kt new file mode 100644 index 000000000..e44bbc6b6 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryKotlin.kt @@ -0,0 +1,30 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinSyntax + +/** + * The Kotlin written into a story as the Kotlin parser reads it. Fields ask about the same few lines every time they are + * built, so what it said is kept. While scripting starts up there is no parser, and every answer is null. + */ +object StoryKotlin { + private const val KEPT = 1024 + private val known = object : LinkedHashMap(KEPT, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > KEPT + } + + val isAvailable: Boolean get() = syntax("") != null + + fun syntax(text: String): KotlinSyntax? = synchronized(known) { + known[text] ?: ScriptingEnvironment.currentOrNull()?.analyzer?.kotlinSyntax(text)?.also { known[text] = it } + } + + /** [text] split at the commas between arguments of a call, or null when it does not read as those. */ + fun arguments(text: String): List? = syntax("list($text)")?.arguments + + /** [text] with what calls the variable [old] calling it [name]. */ + fun renamed(text: String, old: String, name: String): String { + val uses = syntax(text)?.names.orEmpty().filter { it.name == old } + return uses.foldRight(text) { use, code -> code.replaceRange(use.start, use.end, name) } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryKotlinTypes.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryKotlinTypes.kt new file mode 100644 index 000000000..ab22923c5 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryKotlinTypes.kt @@ -0,0 +1,20 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.full.isSubclassOf + +/** Kotlin types as nodes keep them, written out. */ +object StoryKotlinTypes { + private const val STAR = "*" + + /** How Kotlin writes [type]; null when no text can name it, as with a type parameter or a function. */ + fun name(type: KType): String? { + val kind = type.classifier as? KClass<*> ?: return null + if (kind.isSubclassOf(Function::class)) return null + val base = kind.qualifiedName ?: return null + val arguments = type.arguments.map { argument -> argument.type?.let { name(it) ?: return null } ?: STAR } + val listed = if (arguments.isEmpty()) "" else arguments.joinToString(prefix = "<", postfix = ">") + return base + listed + if (type.isMarkedNullable) "?" else "" + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryLiterals.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryLiterals.kt new file mode 100644 index 000000000..28d926020 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryLiterals.kt @@ -0,0 +1,158 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import net.minecraft.core.BlockPos +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.scene.DialogueCharacter +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.full.isSubclassOf +import kotlin.time.Duration + +/** + * Values as the fields of a card show them and as Kotlin writes them. A field holds a plain value of its type, or + * `{expression}` for anything else, so a writer never sees Kotlin they did not type. + */ +object StoryLiterals { + private val CALL = Regex("""^(\w+)\((.*)\)$""") + private const val SECONDS = "s" + private const val VECTOR = "vec" + private const val BLOCK = "blockPos" + private const val ZERO = "Vec3.ZERO" + private const val AXES = 3 + + /** What is typed into a field of [type] as Kotlin. */ + fun expression(text: String, type: KType?): String { + val typed = text.trim() + if (typed.isEmpty()) return "" + if (typed.startsWith("{") && typed.endsWith("}")) return typed.substring(1, typed.length - 1) + val kind = type?.classifier as? KClass<*> ?: return StoryGraphCompiler.literal(text) + return when { + kind == Boolean::class || kind == Int::class -> typed + kind == Double::class -> if (typed.toIntOrNull() != null) "$typed.toDouble()" else typed + kind == Long::class -> numeric(typed, "L") + kind == Float::class -> numeric(typed, "f") + kind == Short::class -> numeric(typed, ".toShort()") + kind == Byte::class -> numeric(typed, ".toByte()") + kind == Duration::class -> call("duration", typed) + kind == ResourceLocation::class -> call("location", typed) + kind == ServerLevel::class -> call("dimension", typed) + kind == Vec3::class -> "vec(${numbers(typed)})" + kind == BlockPos::class -> "blockPos(${numbers(typed)})" + kind.isSubclassOf(DialogueCharacter::class) -> call("character", typed) + kind.isSubclassOf(Entity::class) -> call("actor", typed) + kind.java.isEnum -> "${kind.qualifiedName}.${typed.uppercase().replace('-', '_')}" + isKotlin(type) -> text + + else -> StoryGraphCompiler.literal(text) + } + } + + /** How a field of [type] shows the Kotlin [expression]: back as it was typed, or in braces. */ + fun text(expression: String, type: KType?): String { + val written = expression.trim() + if (written.isEmpty()) return "" + val kind = type?.classifier as? KClass<*> + val plain = when { + kind == null || kind == String::class -> string(written) + kind == Boolean::class || kind == Int::class -> written + kind == Double::class -> written.removeSuffix(".toDouble()") + kind == Long::class -> written.removeSuffix("L") + kind == Float::class -> written.removeSuffix("f") + kind == Short::class -> written.removeSuffix(".toShort()") + kind == Byte::class -> written.removeSuffix(".toByte()") + kind.java.isEnum -> written.substringAfterLast('.').takeIf { written.endsWith(it) && it.uppercase() == it } + ?.lowercase()?.replace('_', '-') + + isKotlin(type) -> expression + + else -> helperArguments(written) + } + return plain ?: "{$written}" + } + + /** What a field of [type] shows for [expression] when that is a plain value; null for any other Kotlin. */ + fun literal(expression: String, type: KType?): String? = + text(expression, type).takeIf { it.isNotBlank() && it != "{${expression.trim()}}" } + + /** Whether a field of [type] holds plain Kotlin, as nothing shorter says what such a value is. */ + fun isKotlin(type: KType?): Boolean = type?.classifier in COLLECTIONS + + /** The strings of a `listOf` of the Kotlin [expression]; anything else holds none. */ + fun listed(expression: String): List { + val inner = expression.trim().removePrefix("listOf(").takeIf { it.endsWith(")") }?.dropLast(1).orEmpty() + return StoryKotlin.arguments(inner).orEmpty().mapNotNull { string(it) } + } + + /** The Kotlin of a list of [values]; with none of them it is left out. */ + fun list(values: List): String = + if (values.isEmpty()) "" else values.joinToString(prefix = "listOf(", postfix = ")") { StoryGraphCompiler.literal(it) } + + /** [typed] with [suffix] when it is a number; anything else is an expression and stays as it was typed. */ + private fun numeric(typed: String, suffix: String) = if (typed.toDoubleOrNull() != null) "$typed$suffix" else typed + + /** The function that writes a value of [type] from its coordinates, or null when it is no vector. */ + fun vectorHelper(type: KType?): String? = when (type?.classifier) { + Vec3::class -> VECTOR + BlockPos::class -> BLOCK + else -> null + } + + /** + * The coordinates of the Kotlin [expression], written by [helper], or null when it is some other expression. + * Nothing written yet is three empty coordinates. + */ + fun vectorParts(expression: String, helper: String): List? { + val written = expression.trim() + if (written.isEmpty()) return List(AXES) { "" } + if (written == ZERO) return List(AXES) { "0" } + if (!written.startsWith("$helper(") || !written.endsWith(")")) return null + return StoryKotlin.arguments(written.substring(helper.length + 1, written.length - 1))?.takeIf { it.size == AXES } + } + + /** The Kotlin of a vector out of its coordinates; with none of them typed it is left out. */ + fun vector(parts: List, helper: String): String = + if (parts.all(String::isBlank)) "" else parts.joinToString(prefix = "$helper(", postfix = ")") { it.trim() } + + private fun helperArguments(written: String): String? { + val match = CALL.matchEntire(written) ?: return null + val inner = match.groupValues[2] + return if (inner.startsWith("\"")) string(inner) else inner.takeIf { it.none { char -> char == '(' } } + } + + private fun call(helper: String, text: String) = "$helper(${StoryGraphCompiler.literal(text)})" + + private fun numbers(text: String) = text.removePrefix("[").removeSuffix("]").split(Regex("""[,\s]+""")) + .filter(String::isNotEmpty).joinToString() + + /** The text of a string literal without inserts, or null for any other expression. */ + private fun string(written: String): String? { + if (written.length < 2 || !written.startsWith("\"") || !written.endsWith("\"")) return null + val inner = written.substring(1, written.length - 1) + return buildString { + var index = 0 + while (index < inner.length) { + val char = inner[index++] + if (char == '"') return null + if (char != '\\') { + if (char == '$') return null + append(char) + continue + } + when (val next = inner.getOrNull(index++) ?: return null) { + 'n' -> append('\n') + 'r' -> append('\r') + 't' -> append('\t') + else -> append(next) + } + } + } + } + + /** A number of [seconds] as a field of a duration shows it. */ + fun seconds(seconds: Float): String = seconds.toString().removeSuffix(".0") + SECONDS + + private val COLLECTIONS = setOf(List::class, Collection::class, Iterable::class, Set::class) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryOperators.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryOperators.kt new file mode 100644 index 000000000..bcbc0de1a --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryOperators.kt @@ -0,0 +1,379 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.player.Player +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.typeOf + +/** What a pin of a node carries, which says what links to it and how its field writes Kotlin. */ +enum class StoryPinType(val type: KType?) { + FLOW(null), + TEXT(typeOf()), + NUMBER(typeOf()), + BOOL(typeOf()), + VECTOR(typeOf()), + ENTITY(typeOf()), + NPC(typeOf()), + PLAYER(typeOf()), + LIST(null), + MAP(null), + + /** A value of a class with no pin of its own; the pins say which, as they do not all fit each other. */ + OBJECT(null), + ANY(null); + + /** What a value of this type is called in Kotlin, for telling the author. */ + val label: String + get() = when (this) { + FLOW, ANY, OBJECT -> "Any?" + TEXT -> "String" + NUMBER -> "Double" + BOOL -> "Boolean" + VECTOR -> "Vec3" + ENTITY -> "Entity" + NPC -> "NpcEntity" + PLAYER -> "ServerPlayer" + LIST -> "List<*>" + MAP -> "Map<*, *>" + } + + /** Whether a value of this type can be linked into a pin of [input]. */ + fun fits(input: StoryPinType): Boolean = this == input || (input == ENTITY && (this == NPC || this == PLAYER)) || + (this != FLOW && input != FLOW && (this == ANY || input == ANY)) + + /** Whether a link of this type gets to the type of its place by conversion, whatever Kotlin says of the two. */ + val converts: Boolean get() = this == NUMBER || this == ENTITY || this == NPC || this == PLAYER || this == ANY + + /** What is typed into a field of this type as Kotlin. */ + fun expression(text: String): String { + if (type != null) return StoryLiterals.expression(text, type) + val typed = text.trim() + return when { + typed.startsWith("{") && typed.endsWith("}") -> typed.substring(1, typed.length - 1) + typed.isEmpty() || this != ANY -> typed + typed == "true" || typed == "false" || typed.toDoubleOrNull() != null -> typed + else -> StoryGraphCompiler.literal(text) + } + } + + /** How a field of this type shows the Kotlin [expression]. */ + fun text(expression: String): String { + if (type != null) return StoryLiterals.text(expression, type) + val written = expression.trim() + return when { + written.isEmpty() || written == "true" || written == "false" || written.toDoubleOrNull() != null -> written + else -> StoryLiterals.text(written, null).takeIf { this == ANY } ?: written + } + } + + companion object { + /** The pin type of a value whose type is written [name], as a node keeps it. */ + fun ofName(name: String?): StoryPinType { + val simple = name?.removeSuffix("?")?.substringBefore('<')?.substringAfterLast('.') ?: return ANY + return when { + simple == "String" -> TEXT + simple == "Boolean" -> BOOL + simple in NUMBERS -> NUMBER + simple == "Vec3" || simple == "BlockPos" -> VECTOR + simple == "NpcEntity" -> NPC + simple.endsWith("Player") -> PLAYER + simple.endsWith("Entity") -> ENTITY + simple == "List" || simple == "Set" -> LIST + simple == "Map" -> MAP + simple == "Any" -> ANY + else -> OBJECT + } + } + + /** The Kotlin type written [name] with no packages, as the author reads it. */ + fun shortName(name: String): String = PACKAGE.replace(name, "").replace("!", "") + + private val PACKAGE = Regex("""\b[a-z_]\w*\.""") + + private val NUMBERS = setOf("Int", "Long", "Short", "Byte", "Float", "Double") + + /** The pin type of a value of the Kotlin type [type]. */ + fun of(type: KType?): StoryPinType { + val kind = type?.classifier as? KClass<*> ?: return ANY + return when { + kind == String::class -> TEXT + kind == Boolean::class -> BOOL + kind.isSubclassOf(Number::class) -> NUMBER + kind == Vec3::class || kind == BlockPos::class -> VECTOR + kind.isSubclassOf(NpcEntity::class) -> NPC + kind.isSubclassOf(Player::class) -> PLAYER + kind.isSubclassOf(Entity::class) -> ENTITY + kind.isSubclassOf(Collection::class) || kind == Iterable::class -> LIST + kind.isSubclassOf(Map::class) -> MAP + kind == Any::class -> ANY + else -> OBJECT + } + } + } +} + +/** An input of a [StoryOperator]. [default] is the Kotlin it reads with nothing typed or linked. */ +data class StoryOperatorInput(val name: String, val type: StoryPinType, val default: String = "") + +/** + * An output of a [StoryOperator]. In [template], `$name` stands for what the input of that name reads. + * + * @param kotlin its exact type, until analysis tells it for the inputs at hand. + * @param empty what it reads with none of the inputs of a variadic operator given. + */ +data class StoryOperatorOutput( + val name: String, + val type: StoryPinType, + val template: String, + val kotlin: String? = null, + val empty: String? = null, +) + +/** + * A value worked out on the spot: no flow goes through it, and every node reading it reads it anew. + * + * @param variadic whether the author can add inputs like the last of [inputs]; `$*` in a template stands for all of them. + */ +data class StoryOperator( + val id: String, + val category: String, + val inputs: List, + val outputs: List, + val variadic: Boolean = false, +) + +/** The operators graphs are built from. */ +object StoryOperators { + private val NUMBER = StoryPinType.NUMBER + private val TEXT = StoryPinType.TEXT + private val BOOL = StoryPinType.BOOL + private val VECTOR = StoryPinType.VECTOR + private val ENTITY = StoryPinType.ENTITY + private val LIST = StoryPinType.LIST + private val MAP = StoryPinType.MAP + private val ANY = StoryPinType.ANY + + private fun input(name: String, type: StoryPinType, default: String = "") = StoryOperatorInput(name, type, default) + + private fun out(name: String, type: StoryPinType, template: String) = StoryOperatorOutput(name, type, template) + + private fun operator(id: String, category: String, inputs: List, vararg outputs: StoryOperatorOutput) = + StoryOperator(id, category, inputs, outputs.toList()) + + private fun number(name: String, default: String = "0") = input(name, NUMBER, default) + + private fun entity(name: String = "entity") = input(name, ENTITY, "storyPlayer()") + + /** Two numbers into one, written over `$a` and `$b` as doubles. */ + private fun math(id: String, template: String, b: String = "0") = operator( + id, "math", listOf(number("a"), number("b", b)), + out("result", NUMBER, template.replace("A", "$" + "a.toDouble()").replace("B", "$" + "b.toDouble()")), + ) + + /** A number into another, written over `$a` as a double. */ + private fun rounded(id: String, template: String) = operator( + id, "math", listOf(number("a")), out("result", NUMBER, template.replace("A", "$" + "a.toDouble()")), + ) + + private fun compare(id: String, sign: String) = operator( + id, "logic", listOf(number("a"), number("b")), out("result", BOOL, "$" + "a.toDouble() $sign $" + "b.toDouble()"), + ) + + private fun logic(id: String, sign: String) = operator( + id, "logic", listOf(input("a", BOOL, "false"), input("b", BOOL, "false")), out("result", BOOL, "$" + "a $sign $" + "b"), + ) + + private fun vectors(id: String, template: String) = operator( + id, "vector", listOf(input("a", VECTOR, "Vec3.ZERO"), input("b", VECTOR, "Vec3.ZERO")), out("result", VECTOR, template), + ) + + val all: List = listOf( + operator( + "player", "data", emptyList(), + out("player", StoryPinType.PLAYER, "storyPlayer()"), + out("level", StoryPinType.OBJECT, "storyLevel()").copy(kotlin = "ServerLevel"), + out("dimension", TEXT, "storyLevel().dimension().location().toString()"), + out("day_time", NUMBER, "storyLevel().dayTime.toDouble()"), + out("raining", BOOL, "storyLevel().isRaining"), + ), + operator("find-player", "data", listOf(input("name", TEXT)), out("player", StoryPinType.PLAYER, "findStoryPlayer(\$name)")), + operator( + "entity-position", "data", listOf(entity()), + out("position", VECTOR, "\$entity.position()"), + out("x", NUMBER, "\$entity.x"), + out("y", NUMBER, "\$entity.y"), + out("z", NUMBER, "\$entity.z"), + out("eyes", VECTOR, "\$entity.eyePosition"), + ), + operator( + "entity-rotation", "data", listOf(entity()), + out("yaw", NUMBER, "\$entity.yRot.toDouble()"), + out("pitch", NUMBER, "\$entity.xRot.toDouble()"), + out("look", VECTOR, "\$entity.lookAngle"), + out("velocity", VECTOR, "\$entity.deltaMovement"), + ), + operator( + "entity-state", "data", listOf(entity()), + out("name", TEXT, "\$entity.name.string"), + out("uuid", TEXT, "\$entity.stringUUID"), + out("dimension", TEXT, "\$entity.level().dimension().location().toString()"), + out("alive", BOOL, "\$entity.isAlive"), + out("on_ground", BOOL, "\$entity.onGround()"), + out("in_water", BOOL, "\$entity.isInWater"), + out("sneaking", BOOL, "\$entity.isShiftKeyDown"), + out("sprinting", BOOL, "\$entity.isSprinting"), + ), + operator( + "entity-health", "data", listOf(entity()), + out("health", NUMBER, "entityHealth(\$entity)"), + out("max_health", NUMBER, "entityMaxHealth(\$entity)"), + ), + operator( + "entity-attribute", "data", listOf(entity(), input("attribute", TEXT, "\"minecraft:generic.max_health\"")), + out("value", NUMBER, "entityAttribute(\$entity, \$attribute)"), + ), + operator( + "player-state", "data", listOf(entity("player")), + out("food", NUMBER, "playerFood(\$player)"), + out("level", NUMBER, "playerLevel(\$player)"), + out("game_mode", TEXT, "playerGameMode(\$player)"), + ), + operator("held-item", "items", listOf(entity("player")), out("held_item", TEXT, "playerHeldItem(\$player)")), + + math("add", "A + B"), + math("subtract", "A - B"), + math("multiply", "A * B", "1"), + math("divide", "A / B", "1"), + math("modulo", "A.mod(B)", "1"), + math("power", "Math.pow(A, B)", "1"), + math("min", "minOf(A, B)"), + math("max", "maxOf(A, B)"), + rounded("abs", "Math.abs(A)"), + rounded("sqrt", "Math.sqrt(A)"), + rounded("floor", "Math.floor(A)"), + rounded("ceil", "Math.ceil(A)"), + rounded("round", "Math.rint(A)"), + rounded("sin", "Math.sin(A)"), + rounded("cos", "Math.cos(A)"), + operator( + "clamp", "math", listOf(number("value"), number("min"), number("max", "1")), + out("result", NUMBER, "\$value.toDouble().coerceIn(\$min.toDouble(), \$max.toDouble())"), + ), + operator( + "lerp", "math", listOf(number("from"), number("to", "1"), number("t", "0.5")), + out("result", NUMBER, "\$from.toDouble() + (\$to.toDouble() - \$from.toDouble()) * \$t.toDouble()"), + ), + operator( + "random", "math", listOf(number("min"), number("max", "1")), + out("result", NUMBER, "\$min.toDouble() + Math.random() * (\$max.toDouble() - \$min.toDouble())"), + ), + + compare("less", "<"), + compare("less-equal", "<="), + compare("greater", ">"), + compare("greater-equal", ">="), + operator("is-null", "logic", listOf(input("value", ANY)), out("result", BOOL, "\$value == null")), + operator("equal", "logic", listOf(input("a", ANY, "0"), input("b", ANY, "0")), out("result", BOOL, "\$a == \$b")), + operator("not-equal", "logic", listOf(input("a", ANY, "0"), input("b", ANY, "0")), out("result", BOOL, "\$a != \$b")), + logic("and", "&&"), + logic("or", "||"), + logic("xor", "!="), + operator("not", "logic", listOf(input("a", BOOL, "false")), out("result", BOOL, "!\$a")), + operator( + "select", "logic", listOf(input("condition", BOOL, "false"), input("then", ANY, "0"), input("else", ANY, "0")), + out("result", ANY, "if (\$condition) \$then else \$else"), + ), + + operator( + "vector", "vector", listOf(number("x"), number("y"), number("z")), + out("vector", VECTOR, "vec(\$x, \$y, \$z)"), + ), + operator( + "vector-split", "vector", listOf(input("vector", VECTOR, "Vec3.ZERO")), + out("x", NUMBER, "\$vector.x"), out("y", NUMBER, "\$vector.y"), out("z", NUMBER, "\$vector.z"), + ), + vectors("vector-add", "\$a.add(\$b)"), + vectors("vector-subtract", "\$a.subtract(\$b)"), + operator( + "vector-scale", "vector", listOf(input("vector", VECTOR, "Vec3.ZERO"), number("factor", "1")), + out("result", VECTOR, "\$vector.scale(\$factor.toDouble())"), + ), + operator( + "distance", "vector", listOf(input("a", VECTOR, "Vec3.ZERO"), input("b", VECTOR, "Vec3.ZERO")), + out("result", NUMBER, "\$a.distanceTo(\$b)"), + ), + operator( + "vector-length", "vector", listOf(input("vector", VECTOR, "Vec3.ZERO")), + out("length", NUMBER, "\$vector.length()"), out("normalized", VECTOR, "\$vector.normalize()"), + ), + operator( + "vector-lerp", "vector", + listOf(input("from", VECTOR, "Vec3.ZERO"), input("to", VECTOR, "Vec3.ZERO"), number("t", "0.5")), + out("result", VECTOR, "\$from.lerp(\$to, \$t.toDouble())"), + ), + + operator("join-text", "text", listOf(input("a", ANY, "\"\""), input("b", ANY, "\"\"")), out("result", TEXT, "\"\${\$a}\${\$b}\"")), + operator( + "format-text", "text", + listOf(input("format", TEXT), input("a", ANY, "\"\""), input("b", ANY, "\"\""), input("c", ANY, "\"\""), input("d", ANY, "\"\"")), + out("result", TEXT, "\$format.format(\$a, \$b, \$c, \$d)"), + ), + operator("to-text", "text", listOf(input("value", ANY, "\"\"")), out("result", TEXT, "\$value.toString()")), + operator("to-number", "text", listOf(input("text", TEXT, "\"0\"")), out("result", NUMBER, "(\$text.toDoubleOrNull() ?: 0.0)")), + operator("text-length", "text", listOf(input("text", TEXT, "\"\"")), out("length", NUMBER, "\$text.length.toDouble()")), + operator( + "text-contains", "text", listOf(input("text", TEXT, "\"\""), input("part", TEXT, "\"\"")), + out("result", BOOL, "\$text.contains(\$part)"), + ), + operator( + "text-replace", "text", listOf(input("text", TEXT, "\"\""), input("from", TEXT, "\"\""), input("to", TEXT, "\"\"")), + out("result", TEXT, "\$text.replace(\$from, \$to)"), + ), + + operator( + "list", "list", listOf(input("a", ANY), input("b", ANY), input("c", ANY)), + out("list", LIST, "listOf(\$*)").copy(kotlin = "List", empty = "emptyList()"), + ).copy(variadic = true), + operator( + "range", "list", listOf(number("from"), number("to", "9")), + out("list", LIST, "(\$from.toInt()..\$to.toInt()).toList()").copy(kotlin = "List"), + ), + operator("list-size", "list", listOf(input("list", LIST)), out("size", NUMBER, "\$list.size.toDouble()")), + operator("list-get", "list", listOf(input("list", LIST), number("index")), out("item", ANY, "\$list[\$index.toInt()]")), + operator("list-contains", "list", listOf(input("list", LIST), input("item", ANY)), out("result", BOOL, "\$item in \$list")), + operator("list-add", "list", listOf(input("list", LIST), input("item", ANY)), out("list", LIST, "\$list + \$item")), + operator("list-remove", "list", listOf(input("list", LIST), input("item", ANY)), out("list", LIST, "\$list - \$item")), + + operator("map-get", "list", listOf(input("map", MAP), input("key", ANY)), out("value", ANY, "\$map[\$key]")), + operator("map-has", "list", listOf(input("map", MAP), input("key", ANY)), out("result", BOOL, "\$key in \$map")), + operator("map-put", "list", listOf(input("map", MAP), input("key", ANY), input("value", ANY)), out("map", MAP, "\$map + (\$key to \$value)")), + operator("map-remove", "list", listOf(input("map", MAP), input("key", ANY)), out("map", MAP, "\$map - \$key")), + operator( + "map-keys", "list", listOf(input("map", MAP)), + out("keys", LIST, "\$map.keys.toList()").copy(kotlin = "List"), out("size", NUMBER, "\$map.size.toDouble()"), + ), + ) + + private val byId = all.associateBy { it.id } + + fun find(id: String): StoryOperator? = byId[id] + + /** The name of the input at [index] of a variadic operator. */ + fun inputName(index: Int): String = if (index < LETTERS) "${'a' + index}" else "item$index" + + /** The inputs of the operator of [node], with those its author added. */ + fun inputsOf(node: StoryNode.Pure): List { + val operator = find(node.operator) ?: return emptyList() + val last = operator.inputs.lastOrNull() + if (!operator.variadic || last == null) return operator.inputs + return List(node.count ?: operator.inputs.size) { last.copy(name = inputName(it)) } + } + + private const val LETTERS = 26 +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryValueFlow.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryValueFlow.kt new file mode 100644 index 000000000..6e0ab8244 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryValueFlow.kt @@ -0,0 +1,107 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +/** + * Whether what nodes read exists by the time flow gets to them. A link says where a value comes from, not that flow + * passed the node that gives it: that takes the source being on every way from a start to the reader. + */ +internal class StoryValueFlow(private val graph: StoryGraph, private val index: StoryLinkIndex) { + private val nodes = graph.nodes.associateBy { it.id } + private val inFlow = graph.links.filter { it.isFlow }.flatMapTo(HashSet()) { listOf(it.from, it.to) } + + /** Every node reached from a start with the nodes flow cannot get to it without. */ + private val passed: Map> = dominators() + + fun problems(): List = passed.keys.sorted().flatMap { reader -> + sources(reader).mapNotNull { source -> problem(reader, source) } + }.distinct() + + private fun problem(reader: String, source: StoryLink): StoryGraphProblem? { + val node = nodes.getValue(source.from) + return if (node is StoryNode.Loop) itemProblem(reader, node) else resultProblem(reader, source) + } + + /** The item is a local of the `for`, which only holds the nodes that are not stages of their own. */ + private fun itemProblem(reader: String, loop: StoryNode.Loop): StoryGraphProblem? { + val inFor = body(loop.id) { index.isMerge(it) || nodes[it] is StoryNode.Start } + return when (reader) { + in inFor -> null + in body(loop.id) -> StoryGraphProblem( + reader, "loop_item_merged", + "Flow gets here through a node several links lead to, which runs apart from the loop and cannot read its item: put the item into a variable first", + ) + + else -> StoryGraphProblem(reader, "loop_item", "The item of a loop only exists in the body of that loop") + } + } + + /** A result nothing has set yet is null, which is an error unless it can be null anyway. */ + private fun resultProblem(reader: String, source: StoryLink): StoryGraphProblem? { + val node = source.from + if (node != reader && node in passed.getValue(reader) && reader !in body(node)) return null + return StoryGraphProblem( + reader, "other_branch", "Flow can get here without passing the node this value comes from", + warning = isOptional(source), + ) + } + + private fun isOptional(source: StoryLink): Boolean = when (val node = nodes[source.from]) { + is StoryNode.Call -> node.returns + is StoryNode.Code -> node.outputs[source.fromPin] + else -> null + }?.endsWith("?") == true + + /** The links that bring [reader] results of nodes of the flow, directly or through values worked out on the way. */ + private fun sources(reader: String): List { + val found = LinkedHashSet() + val seen = hashSetOf(reader) + val queue = ArrayDeque(index.valuesInto(reader)) + while (queue.isNotEmpty()) { + val link = queue.removeFirst() + val node = nodes[link.from] ?: continue + when { + node is StoryNode.Loop || (node.id in inFlow && (node is StoryNode.Call || node is StoryNode.Code)) -> found += link + node.id !in inFlow && seen.add(node.id) -> queue += index.valuesInto(node.id) + } + } + return found.toList() + } + + /** What flow goes through from the body of [node] until it gets to where [stops] says. */ + private fun body(node: String, stops: (String) -> Boolean = { it == node }): Set { + val seen = HashSet() + val queue = ArrayDeque(listOfNotNull(index.flow[node to StoryPins.BODY])) + while (queue.isNotEmpty()) { + val at = queue.removeFirst() + if (!stops(at) && seen.add(at)) queue += index.flowOut(at) + } + return seen + } + + private fun dominators(): Map> { + val starts = graph.nodes.filterIsInstance().map { it.id } + val reached = LinkedHashSet() + val queue = ArrayDeque(starts) + while (queue.isNotEmpty()) { + val at = queue.removeFirst() + if (reached.add(at)) queue += index.flowOut(at) + } + val before = reached.associateWith { node -> index.flowIn(node).filter { it in reached } } + val result = + reached.associateWithTo(HashMap()) { node -> if (node in starts) setOf(node) else reached as Set } + var changed = true + while (changed) { + changed = false + for (node in reached) { + if (node in starts) continue + val common = + before.getValue(node).map(result::getValue).reduceOrNull { all, set -> all intersect set }.orEmpty() + val next = common + node + if (next != result[node]) { + result[node] = next + changed = true + } + } + } + return result + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCatalog.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCatalog.kt new file mode 100644 index 000000000..12746de68 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCatalog.kt @@ -0,0 +1,157 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.locale.Language +import net.minecraft.network.chat.Component +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryCatalog.paramHelp +import ru.hollowhorizon.hollowengine.addons.narrative.story.ActionCommands +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion +import ru.hollowhorizon.hollowengine.client.utils.lang + +/** What can be added to a story: the steps of its own and the commands stories know, under shared headings. */ +internal object StoryCatalog { + private const val NAMESPACE = "hollowengine-narrative" + private const val PARAM = "hollowengine.story.param" + private const val OPERATOR = "hollowengine.story.operator" + private const val HEADING = StoryCategories.PREFIX + private const val HEX = 16 + private const val OPAQUE = 0xFF shl 24 + + fun signatureOf(call: StoryNode.Call): StoryFunctionSignature? = StoryActionFunctions.signature(call) + + fun title(signature: StoryFunctionSignature): String = + signature.title?.lang ?: known(key(signature.name)) ?: "@${signature.name.substringAfter(':')}" + + fun description(signature: StoryFunctionSignature): String? = + signature.description?.lang ?: known(key(signature.name) + ".description") + + /** What the editor calls [param]: its own title, the key under the command, the shared key, its name. */ + fun paramTitle(signature: StoryFunctionSignature, param: StoryParam): String = + param.title?.lang ?: known("${key(signature.name)}.${param.name}") ?: known("$PARAM.${param.name}") + ?: param.name + + fun paramHelp(signature: StoryFunctionSignature, param: StoryParam): String? = + param.description?.lang ?: known("${key(signature.name)}.${param.name}.description") + ?: known("$PARAM.${param.name}.description") + + fun operatorTitle(id: String): String = known("$OPERATOR.$id") ?: readable(id) + + /** What a pin of an operator is called: pins of the same meaning share one name. */ + fun operatorPinTitle(name: String): String = known("$OPERATOR.pin.$name") ?: readable(name) + + /** [paramHelp] of [param], and that it can be left out. */ + fun paramHint(signature: StoryFunctionSignature, param: StoryParam): String? { + val note = storyLang("param.optional").takeIf { param.optional } + return listOfNotNull(paramHelp(signature, param), note).joinToString("\n").ifEmpty { null } + } + + /** The color of the nodes of [signature]: the one it gives its category, or that of the category. */ + fun color(signature: StoryFunctionSignature): UiColor? { + signature.categoryColor?.removePrefix("#")?.toIntOrNull(HEX)?.let { return UiColor.fromArgb(OPAQUE or it) } + return signature.category?.let(StoryCategories::color) + } + + /** The icon of the nodes of [signature]: its own, or that of its category. */ + fun icon(signature: StoryFunctionSignature): String? = + signature.icon ?: signature.category?.let(StoryCategories::icon) + + /** The key of the category of operators [category] is: they share the categories of commands. */ + fun operatorCategory(category: String) = HEADING + category + + fun operatorColor(operator: StoryOperator): UiColor = StoryCategories.color(operatorCategory(operator.category)) + + fun operatorIcon(operator: StoryOperator): String? = StoryCategories.icon(operatorCategory(operator.category)) + + /** The heading a category sits under: `effects.sound` under `effects`, and one of the top level under itself. */ + private fun rootOf(category: String) = + if ('.' in category.substringAfter(HEADING, "")) category.substringBeforeLast('.') else category + + /** What the input [name] of the operator [id] takes, when that needs saying. */ + fun operatorPinHint(id: String, name: String): String? = known("$OPERATOR.$id.$name.description") + + private var attributesLanguage: Language? = null + private var attributes = emptyList() + + private fun readable(id: String) = id.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.uppercase() } + + /** What the input [name] of an operator is usually given, to complete as it is typed. */ + fun inputHints(name: String): List = if (name == "attribute") attributeHints() else emptyList() + + /** The attributes under the language they were last named in: fields ask for them each time a node is laid out. */ + private fun attributeHints(): List { + val language = Language.getInstance() + if (language !== attributesLanguage) { + attributesLanguage = language + attributes = BuiltInRegistries.ATTRIBUTE.entrySet().map { (key, attribute) -> + val id = key.location().toString() + val title = Component.translatable(attribute.descriptionId).string + UiTextCompletion(label = id, tail = title, filterText = "$id $title", wordChars = ":/._-") + }.sortedBy { it.label } + } + return attributes + } + + /** A heading per category of operators with those [accepts] lets through, for adding a value. */ + fun operatorItems(accepts: (StoryOperator) -> Boolean = { true }, add: (StoryNode) -> Unit): List = + StoryOperators.all.filter(accepts).groupBy { it.category }.map { (category, operators) -> + heading(operatorCategory(category), operators.map { operator -> + UiDropdownItem( + operatorTitle(operator.id), + icon = operatorIcon(operator) ?: StoryNodeKind.VALUE.icon, + iconColor = StoryCategories.tint(operatorColor(operator)), + ) { add(StoryNode.Pure(StoryGraph.newId(), operator.id)) } + }) + } + + /** The submenu of [category], in its icon and color. */ + fun heading(category: String, children: List) = UiDropdownItem( + category.lang, + icon = StoryCategories.icon(category), + iconColor = StoryCategories.tint(StoryCategories.color(category)), + children = children, + ) + + /** An item that adds a node of [kind], in its icon and color. */ + fun kindItem(label: String, kind: StoryNodeKind, onClick: () -> Unit) = + UiDropdownItem(label, icon = kind.icon, iconColor = StoryCategories.tint(kind.color), onClick = onClick) + + /** A menu item per command and step of the flow, for adding a node of it. */ + fun items(add: (StoryNode) -> Unit): List { + val own = StoryBuiltins.all.map { it.category to item(it, add) } + listOf( + "${HEADING}variables" to kindItem(storyLang("kind.set"), StoryNodeKind.SET) { + add(StoryNode.Assign(StoryGraph.newId(), "")) + }, + "${HEADING}flow" to kindItem(storyLang("kind.code"), StoryNodeKind.CODE) { + add(StoryNode.Code(StoryGraph.newId(), "")) + }, + ) + val commands = ActionCommands.commands.map { it.signature }.filter { !it.hidden }.sortedBy(::title) + .map { it.category to item(it, add) } + val all = own + commands + val headings = all.mapNotNull { it.first }.groupBy(::rootOf).map { (root, categories) -> + val inner = categories.distinct().filter { it != root }.map { category -> + heading(category, all.filter { it.first == category }.map { it.second }) + }.sortedBy { it.label } + heading(root, inner + all.filter { it.first == root }.map { it.second }) + } + return headings.sortedBy { it.label } + all.filter { it.first == null }.map { it.second } + } + + private fun item(signature: StoryFunctionSignature, add: (StoryNode) -> Unit) = + UiDropdownItem( + title(signature), + icon = icon(signature) ?: StoryNodeKind.COMMAND.icon, + iconColor = StoryCategories.tint(color(signature) ?: StoryNodeKind.COMMAND.color), + ) { + add(signature.newCall(StoryGraph.newId())) + } + + /** The key of [id]: `ns:path` is named by `ns.story.command.path`. */ + private fun key(id: String) = "${id.substringBefore(':', NAMESPACE)}.story.command.${id.substringAfter(':')}" + + private fun known(key: String): String? = key.takeIf(Language.getInstance()::has)?.lang +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCategories.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCategories.kt new file mode 100644 index 000000000..c9ac18eb8 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCategories.kt @@ -0,0 +1,85 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import ru.hollowhorizon.hollowengine.client.HighlightTheme +import ru.hollowhorizon.hollowengine.client.ui.UiColor + +class StoryCategoryStyle(val color: UiColor, val icon: String?) + +object StoryCategories { + const val PREFIX = "hollowengine.gui.ide.story.category." + private const val OPAQUE = 0xFF shl 24 + private const val HUES = 360 + private const val DIMMED = 0.6f + private const val LIT = 0.92f + + private val styles = HashMap() + + init { + builtin("animation", 0xB5577F, "film") + builtin("camera", 0x4E7FB0, "timeline/capture") + builtin("data", 0x7A7040, "story/category/data", NARRATIVE_ICONS) + builtin("dialogue", 0x3B8950, "story/phrase", NARRATIVE_ICONS) + builtin("effects", 0xA0569E, "vfx/particle") + builtin("effects.materials", 0x8F5FA8, "vfx/material") + builtin("effects.screen", 0x9C5A86, "vfx/post") + builtin("effects.sound", 0xA0569E, "volume") + builtin("flow", 0x5F59B1, "story/block", NARRATIVE_ICONS) + builtin("items", 0xA86E3A, "recipes") + builtin("list", 0x5A8A3C, "layers") + builtin("logic", 0x7A5FB5, "logic") + builtin("math", 0x2F7D80, "math") + builtin("navigation", 0x3A8FA0, "story/category/navigation", NARRATIVE_ICONS) + builtin("npc", 0xB0683E, "story/category/npc", NARRATIVE_ICONS) + builtin("story", 0xCF5E54, "story/label", NARRATIVE_ICONS) + builtin("text", 0x9A7A3A, "files/text") + builtin("time", 0x3E86C4, "story/category/time", NARRATIVE_ICONS) + builtin("tracks", 0x4F9A78, "star") + builtin("variables", 0x6A7590, "variables") + builtin("vector", 0x4F6FC0, "graph/coordinates") + builtin("world", 0x5E8C3A, "world") + } + + /** Gives the category of [key] its own [color], `0xRRGGBB`, and the [icon] of its nodes. */ + fun register(key: String, color: Int, icon: String? = null) { + styles[key] = StoryCategoryStyle(UiColor.fromArgb(OPAQUE or color), icon) + } + + fun color(key: String): UiColor = style(key)?.color ?: worked(root(key)) + + fun icon(key: String): String? = style(key)?.icon + + /** [color] raised to stand out as an icon on the dark of a menu, keeping its hue. */ + fun tint(color: UiColor): UiColor { + val scale = LIT / maxOf(color.red, color.green, color.blue, 1e-3f) + return UiColor(color.red * scale, color.green * scale, color.blue * scale, color.alpha) + } + + private fun style(key: String): StoryCategoryStyle? { + var at: String? = key + while (at != null) { + styles[at]?.let { return it } + at = parent(at) + } + return null + } + + private fun parent(key: String): String? { + val name = key.removePrefix(PREFIX) + return if (name != key && '.' in name) PREFIX + name.substringBeforeLast('.') else null + } + + private fun root(key: String): String = parent(key)?.let(::root) ?: key + + private fun worked(key: String): UiColor { + val argb = HighlightTheme.paletteArgb(Math.floorMod(key.hashCode(), HUES)) + return UiColor(channel(argb shr 16), channel(argb shr 8), channel(argb)) + } + + private fun channel(value: Int) = (value and 0xFF) / 255f * DIMMED + + private fun builtin(name: String, color: Int, icon: String, icons: String = ENGINE_ICONS) = + register(PREFIX + name, color, "$icons$icon.svg") +} + +private const val ENGINE_ICONS = "hollowengine:textures/gui/icons/" +private const val NARRATIVE_ICONS = "hollowengine-narrative:textures/gui/icons/" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryChecks.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryChecks.kt new file mode 100644 index 000000000..7e4df1289 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryChecks.kt @@ -0,0 +1,153 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryChecks.param +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryChecks.variable +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.time.Duration + +/** Something wrong with the node [node], or with the story as a whole when it is null. [warning] lets the story run all the same. */ +internal class StoryCheck(val node: String?, val message: String, val warning: Boolean = false) + +/** What the editors can tell is wrong with a story before it is compiled. Every answer is a line for the author. */ +internal object StoryChecks { + private val LOCATION = Regex("""([a-z0-9_.-]+:)?[a-z0-9_./-]+""") + private val NAME = Regex("[\\p{L}_][\\p{L}\\p{N}_]*") + + private fun problem(name: String) = storyLang("problem.$name") + + /** What is wrong with the Kotlin [expression] given to [param], which nothing is linked to. Analysis sees the rest. */ + fun param(param: StoryParam, expression: String): String? { + if (expression.isBlank()) return if (param.optional) null else problem("required") + return format(StoryLiterals.text(expression, param.type), param.type) ?: code(expression) + } + + /** What is wrong with the text [shown] by a field of [type], as Kotlin takes any string where a format is wanted. */ + private fun format(shown: String, type: KType?): String? { + val kind = type?.classifier as? KClass<*> ?: return null + if (shown.startsWith("{")) return null + return when { + kind == Duration::class -> if (isDuration(shown)) null else problem("duration") + kind == ResourceLocation::class || kind == ServerLevel::class -> if (LOCATION.matches(shown)) null else problem( + "location" + ) + + else -> null + } + } + + private fun isNumber(text: String) = text.toDoubleOrNull() != null && !text.endsWith('.') + + private fun isDuration(text: String) = isNumber(text) || runCatching { Duration.parse(text) }.isSuccess + + /** What the parser finds wrong with the Kotlin [text]. */ + fun code(text: String): String? = if (StoryKotlin.syntax(text)?.valid == false) problem("code") else null + + /** What keeps [name] from being the name of a variable among those [taken]. */ + fun variableName(name: String, taken: Collection): String? = when { + !NAME.matches(name) || StoryKotlin.syntax(name)?.tokens?.isNotEmpty() == true -> problem("name") + name in taken -> problem("name_taken") + else -> null + } + + /** What is wrong with what [variable] starts as; a variable that can be empty need not start with anything. */ + fun variable(variable: StoryVariable): String? = when { + variable.initial.isNotBlank() -> code(variable.initial) + variable.type?.endsWith("?") == true -> null + else -> problem("required") + } + + /** [problem] in the language of the author. */ + fun text(problem: StoryGraphProblem): String = + storyLang("problem.graph.${problem.key}", *problem.arguments.toTypedArray()) + + /** Everything wrong with [graph]: what the compiler says, and what the editors can see. */ + fun all(graph: StoryGraph): List { + val compiled = StoryGraphCompiler.compile(graph, StoryActionFunctions).problems.map { + StoryCheck(it.node, text(it), it.warning) + } + val variables = graph.variables.mapNotNull { variable -> + variable(variable)?.let { + StoryCheck( + null, + "${variable.name}: $it" + ) + } + } + return compiled + variables + nodes(graph) + } + + private fun warning(node: String, name: String) = StoryCheck(node, problem(name), warning = true) + + /** What is wrong with the nodes of [graph]. */ + private fun nodes(graph: StoryGraph): List { + val plain = graph.dissolved() + val reached = reached(plain) + val fed = plain.links.filter { !it.isFlow }.mapTo(HashSet()) { it.to to it.toPin } + val read = plain.links.filter { !it.isFlow }.mapTo(HashSet()) { it.from } + return plain.nodes.flatMap { node -> + fun free(pin: String) = (node.id to pin) !in fed + val own = when (node) { + is StoryNode.Call -> call(node, ::free) + is StoryNode.Pure -> pure(node, ::free) + is StoryNode.Say -> emptyList() + is StoryNode.Branch -> filled(node.condition, free(StoryPins.CONDITION)) + is StoryNode.Loop -> filled(node.items, free(StoryPins.ITEMS)) + is StoryNode.Assign -> filled( + node.value, + free(StoryPins.VALUE) + ) + listOfNotNull(problem("no_variable").takeIf { graph.variables.none { it.name == node.variable } }) + + is StoryNode.Expression -> filled(node.code, true) + is StoryNode.Code -> listOfNotNull(code(node.code)) + else -> emptyList() + } + val empty = node is StoryNode.Say && node.text.isBlank() && free(StoryPins.TEXT) + val value = + node.id in read && !plain.index.isInFlow(node.id) && (node is StoryNode.Call || node is StoryNode.Assign || node is StoryNode.Code) + val lost = node.id !in reached && needsFlow(node) && !value + own.map { StoryCheck(node.id, it) } + listOfNotNull( + warning(node.id, "empty_text").takeIf { empty }, + warning(node.id, "unreachable").takeIf { lost }, + ) + } + } + + private fun filled(code: String, free: Boolean): List = when { + !free -> emptyList() + code.isBlank() -> listOf(problem("required")) + else -> listOfNotNull(code(code)) + } + + private fun call(node: StoryNode.Call, free: (String) -> Boolean): List { + val signature = StoryCatalog.signatureOf(node) ?: return emptyList() + return signature.params.filter { it.group != null || free(it.name) }.mapNotNull { param -> + param(param, node.argument(param).orEmpty())?.let { "${StoryCatalog.paramTitle(signature, param)}: $it" } + } + } + + private fun pure(node: StoryNode.Pure, free: (String) -> Boolean): List = + StoryOperators.inputsOf(node).filter { free(it.name) }.mapNotNull { input -> + val written = node.arguments[input.name].orEmpty() + val wrong = if (written.isBlank() && input.default.isBlank()) problem("required") else code(written) + wrong?.let { "${StoryCatalog.operatorPinTitle(input.name)}: $it" } + } + + private fun needsFlow(node: StoryNode) = + node !is StoryNode.Start && node !is StoryNode.Pure && node !is StoryNode.Expression + + /** The nodes flow gets to from the starts of [graph]. */ + private fun reached(graph: StoryGraph): Set { + val seen = HashSet() + val queue = ArrayDeque(graph.nodes.filterIsInstance().map { it.id }) + while (queue.isNotEmpty()) { + val at = queue.removeFirst() + if (seen.add(at)) queue += graph.index.flowOut(at) + } + return seen + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryClips.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryClips.kt new file mode 100644 index 000000000..12c981e2e --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryClips.kt @@ -0,0 +1,45 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import androidx.compose.runtime.* +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryEntityModel +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryEntityModels +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import ru.hollowhorizon.hollowengine.client.editor.WorldNpcModels +import ru.hollowhorizon.hollowengine.client.models.internal.Model +import ru.hollowhorizon.hollowengine.client.models.internal.manager.HollowModelManager + +internal data class StoryNodeClips(val model: String, val names: List?) + +internal data class StoryClips(private val byNode: Map) { + fun of(node: String): StoryNodeClips? = byNode[node] + + companion object { + val None = StoryClips(emptyMap()) + } +} + +@Composable +internal fun rememberStoryClips(graph: StoryGraph): StoryClips { + val sources = remember(graph) { StoryEntityModels.of(graph, StoryActionFunctions::signature) } + if (sources.isEmpty()) return StoryClips.None + val models = sources.mapValues { (_, source) -> + when (source) { + is StoryEntityModel.Path -> source.model + is StoryEntityModel.Npc -> WorldNpcModels.of(source.id)?.model + } + } + val names = HashMap?>() + models.values.filterNotNull().distinct().forEach { path -> + key(path) { names[path] = clipsOf(path) } + } + return StoryClips(models.mapNotNull { (node, path) -> path?.let { node to StoryNodeClips(it, names[it]) } }.toMap()) +} + +@Composable +private fun clipsOf(path: String): List? { + val location = remember(path) { ResourceLocation.tryParse(path) } ?: return null + val model by remember(location) { HollowModelManager.getOrCreate(location) }.collectAsState() + return model.takeIf { it !== Model.EMPTY }?.animations?.map { it.name }?.sorted() +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCodeDialog.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCodeDialog.kt new file mode 100644 index 000000000..83c888cc7 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryCodeDialog.kt @@ -0,0 +1,93 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.addons.narrative.ide.graph.StoryRowField +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.CenteredOnViewport +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorIconButton +import ru.hollowhorizon.hollowengine.client.ui.widgets.ColorPicker +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeEditor +import kotlin.math.roundToInt + +private const val WIDTH = 560 +private const val HEIGHT = 340 +private const val OPAQUE = 0xFF shl 24 +private const val HEX = 16 +private const val CHANNEL = 255f +private const val CloseIcon = "hollowengine:textures/gui/icons/close.png" + +/** Kotlin of a field opened in a full editor. */ +internal class StoryCodeField(val key: StoryFieldKey, val title: String, val value: String, val write: (String) -> Unit) + +/** The full editor of [field], with line numbers and everything analysis knows. Every change is written at once. */ +@Composable +internal fun StoryCodeDialog(state: StoryEditorState, field: StoryCodeField) { + var text by remember(field) { mutableStateOf(field.value) } + val expected = StoryInsight.expected(state, field.key) + Popup( + anchorBounds = LocalUiViewport.current, + alignment = CenteredOnViewport, + id = "story-code-dialog", + tags = listOf("dropdown-popup", "story-code-dialog"), + layer = 100, + modal = true, + onDismiss = { state.codeField = null }, + ) { + Column(tags = listOf("story-code-frame"), modifier = Modifier.size(WIDTH.px, HEIGHT.px)) { + Row(tags = listOf("story-code-head")) { + Text(field.title, tags = listOf("story-code-title"), modifier = Modifier.grow(1f)) + expected?.let { Text(it, tags = listOf("story-code-type")) } + InspectorIconButton(CloseIcon, storyLang("code.close"), id = "story-code-close") { + state.codeField = null + } + } + Box(tags = listOf("story-code-body"), modifier = Modifier.size(100.percent, 0.px).grow(1f)) { + UiCodeEditor( + value = text, + onChange = { + text = it + field.write(it) + }, + insight = StoryInsight.field(state, field.key, text), + readOnly = state.context?.readOnly == true, + fontSize = 9f, + id = "story-code-editor", + ) + } + } + } +} + +/** The color of [field], written as `#RRGGBB` as it is picked. */ +@Composable +internal fun StoryColorDialog(state: StoryEditorState, field: StoryRowField) { + var color by remember(field.key) { mutableStateOf(storyColor(field.value)) } + Popup( + anchorBounds = LocalUiViewport.current, + alignment = CenteredOnViewport, + id = "story-color-dialog", + tags = listOf("dropdown-popup", "story-color-dialog"), + layer = 100, + modal = true, + onDismiss = { state.colorField = null }, + ) { + ColorPicker( + color, + { + color = it + field.write(hex(it)) + }, + showAlpha = false, + id = "story-color-picker", + ) + } +} + +/** The color a field of `#RRGGBB` holds. */ +internal fun storyColor(value: String): UiColor = + value.trim().removePrefix("#").toIntOrNull(HEX)?.let { UiColor.fromArgb(OPAQUE or it) } ?: UiColor(0f, 0f, 0f) + +private fun hex(color: UiColor): String { + fun channel(value: Float) = (value * CHANNEL).roundToInt().toString(HEX).padStart(2, '0') + return "#" + (channel(color.red) + channel(color.green) + channel(color.blue)).uppercase() +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryEditorState.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryEditorState.kt new file mode 100644 index 000000000..6ea6978a6 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryEditorState.kt @@ -0,0 +1,86 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.edit +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode +import ru.hollowhorizon.hollowengine.addons.narrative.ide.graph.StoryRowField +import ru.hollowhorizon.hollowengine.client.history.UndoLabel +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdePreviewContext +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdeTextModel +import ru.hollowhorizon.hollowengine.client.utils.lang + +internal const val StoryEditorStylesheet = "hollowengine-narrative:ui/styles/story-editor.hss" + +internal fun storyLang(name: String) = "hollowengine.gui.ide.story.$name".lang + +internal fun storyLang(name: String, vararg arguments: Any) = "hollowengine.gui.ide.story.$name".lang(*arguments) + +internal fun storyIcon(name: String) = "hollowengine-narrative:textures/gui/icons/story/$name.svg" + +/** What the edits of a story are called in the history window. */ +internal object StoryUndo { + private const val LANG = "hollowengine.gui.history.story" + + val EDIT = UndoLabel("$LANG.edit") + val ADD = UndoLabel("$LANG.add") + val REMOVE = UndoLabel("$LANG.remove") + val MOVE = UndoLabel("$LANG.move") + val DUPLICATE = UndoLabel("$LANG.duplicate") + val LINK = UndoLabel("$LANG.link") +} + +/** A node of a story, or the branch of it that leaves by [pin]. */ +internal data class StorySelection(val node: String, val pin: String? = null) + +/** What the editors of a `.story` share: the file as a graph and what is selected. Every edit is written to the file. */ +internal open class StoryEditorState : AutoCloseable { + /** The file drawn now; the inspector, drawn elsewhere, reads and writes through it. */ + var context: HollowIdePreviewContext? = null + + var insight: StoryInsight.Session? = null + + val model = HollowIdeTextModel(StoryGraph::decode) + var selected by mutableStateOf(null) + + /** The field open in the full editor of Kotlin. */ + var codeField by mutableStateOf(null) + + /** The field whose color is being picked. */ + var colorField by mutableStateOf(null) + + var clips by mutableStateOf(StoryClips.None) + + /** The names of the variables open in the inspector. */ + var openVariables by mutableStateOf(emptySet()) + + /** Whether the inspector shows the imports of the story. */ + var showsImports by mutableStateOf(false) + + /** Whether the inspector shows the Kotlin of the story. */ + var showsKotlin by mutableStateOf(false) + + fun read(): StoryGraph? = context?.let { model.read(it.text).getOrNull() } + + /** + * Writes what [change] makes of the story. + * + * @param label what the step is called in the history window. + * @param mergeKey folds the step into the one just before with the same key, as for the letters typed into a field. + */ + fun change(label: UndoLabel = StoryUndo.EDIT, mergeKey: String? = null, change: StoryGraph.() -> StoryGraph) { + val context = context?.takeUnless { it.readOnly } ?: return + val graph = read() ?: return + context.edit(graph.change().encode(), label, mergeKey) + } + + /** Changes the node [id]; typing into [field] of it is one step of the history. */ + fun editNode(id: String, field: String, change: (StoryNode) -> StoryNode) = + change(mergeKey = "$id/$field") { edit(id, change) } + + override fun close() { + insight?.close() + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryFieldChecks.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryFieldChecks.kt new file mode 100644 index 000000000..4becfdb90 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryFieldChecks.kt @@ -0,0 +1,181 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions + +/** A field of Kotlin in a story: [key] of the node or variable [owner]. */ +internal data class StoryFieldKey(val owner: String, val key: String) + +/** Where a field sits in a [StoryCheckScript]: its Kotlin from [start] to [end], in a function from [from] to [to]. */ +internal class StoryFieldSpan( + val field: StoryFieldKey, + val type: String?, + val from: Int, + val start: Int, + val end: Int, + val to: Int, +) + +/** What goes around the Kotlin of a field for it to mean what it means on its node. */ +internal class StoryFieldFrame(val before: String, val after: String) + +/** + * The script of a story, whose first [length] characters it generated, followed by every field as its own function and + * a property per output worked out on the spot. + * + * @param spans the fields it holds. + * @param left the fields left out of it, empty or broken as they are, for completing what is typed there. + * @param pins the node and pin behind the name of each such property. + * @param places the type behind the name of each property that stands for what links go into. + */ +internal class StoryCheckScript( + val text: String, + val length: Int, + val spans: List, + private val left: Map, + val pins: Map>, + val places: Map, +) { + private val byField = spans.associateBy { it.field } + + fun span(field: StoryFieldKey): StoryFieldSpan? = byField[field] + + fun at(offset: Int): StoryFieldSpan? = spans.firstOrNull { offset >= it.from && offset < it.to } + + fun frame(field: StoryFieldKey): StoryFieldFrame? = left[field] +} + +/** + * Puts every field of a story under the type it has to be and names every output worked out on the spot, as nothing but + * the Kotlin compiler can tell what they are. This takes one analysis for all of them, in the file the story compiles to, + * so they get their colors, problems and types at once, on nodes the flow does not reach too. + */ +internal object StoryFieldChecks { + /** The start of the names of the properties that give the types of pins. */ + const val PIN = "storyPin" + + /** The start of the names of the properties that give what links go into. */ + const val PLACE = "storyPlace" + + private class Entry(val field: StoryFieldKey, val type: String?, val code: String, val frame: StoryFieldFrame) + + private const val VALUE_END = "\n)" + + fun variableOwner(name: String) = "variable:$name" + + private fun value(owner: String, key: String, type: String?, code: String): Entry { + val declared = type?.let { ": $it" }.orEmpty() + return Entry(StoryFieldKey(owner, key), type, code, StoryFieldFrame("val storyValue$declared = (\n", VALUE_END)) + } + + /** Kotlin with [locals] in reach; what it leaves in the variables [outputs] is read after it, as the outputs of its node are. */ + private fun block(owner: String, key: String, code: String, locals: String, outputs: Set): Entry { + val read = if (outputs.isEmpty()) "" else outputs.joinToString(prefix = "\nlistOf(", postfix = ")") + return Entry(StoryFieldKey(owner, key), null, code, StoryFieldFrame("${locals}run {\n", "\n}$read")) + } + + private fun nodeFields(graph: StoryGraph, node: StoryNode): List { + fun free(pin: String) = (node.id to pin) !in graph.index.values + return when (node) { + is StoryNode.Branch -> listOfNotNull(value(node.id, "condition", "Boolean", node.condition).takeIf { + free( + StoryPins.CONDITION + ) + }) + + is StoryNode.Assign -> listOfNotNull( + value( + node.id, + "value", + graph.variables.firstOrNull { it.name == node.variable }?.type, + node.value + ).takeIf { free(StoryPins.VALUE) }, + ) + + is StoryNode.Loop -> listOfNotNull(value(node.id, "items", "Iterable<*>", node.items).takeIf { + free( + StoryPins.ITEMS + ) + }) + + is StoryNode.Expression -> listOf(value(node.id, "code", null, node.code)) + is StoryNode.Choose -> node.options.mapIndexedNotNull { index, option -> + option.condition?.let { value(node.id, "condition-$index", "Boolean", it) } + } + + is StoryNode.Code -> code(graph, node) + is StoryNode.Call -> call(node, ::free) + is StoryNode.Pure -> StoryOperators.inputsOf(node).filter { free(it.name) }.map { input -> + val type = + if (input.type == StoryPinType.NUMBER) "Number" else input.type.type?.let(StoryKotlinTypes::name) + value(node.id, input.name, type, node.arguments[input.name].orEmpty()) + } + + else -> emptyList() + } + } + + private fun code(graph: StoryGraph, node: StoryNode.Code): List { + val inputs = node.inputs.keys.filter { (node.id to it) !in graph.index.values } + val written = inputs.map { value(node.id, "in-$it", null, node.inputs[it].orEmpty()) } + if (inputs.size != node.inputs.size) return written + val locals = + node.inputs.entries.joinToString("") { (name, code) -> "val $name = (\n$code\n)\n" } + node.outputs.entries.joinToString( + "" + ) { (name, type) -> "var $name: $type? = null\n" } + return written + block(node.id, "code", node.code, locals, node.outputs.keys) + } + + private fun call(node: StoryNode.Call, free: (String) -> Boolean): List { + val call = StoryActionFunctions.resolved(node) + val signature = StoryActionFunctions.signature(call) ?: return emptyList() + return signature.params.filter { free(it.name) }.mapNotNull { param -> + param.type?.let(StoryKotlinTypes::name) + ?.let { value(node.id, param.name, it, call.argument(param).orEmpty()) } + } + } + + private fun reads(code: String) = code.isNotBlank() && StoryKotlin.syntax(code)?.valid == true + + /** The types the nodes of [graph] and those a wire can make for its variables want what is linked to them to be of. */ + private fun places(graph: StoryGraph): Set = (graph.nodes.filterIsInstance().flatMap { node -> + StoryActionFunctions.signature(StoryActionFunctions.resolved(node))?.params.orEmpty() + .mapNotNull { it.type?.let(StoryKotlinTypes::name) } + } + graph.variables.mapNotNull { it.type }).filterTo(LinkedHashSet()) { reads("null as $it") } + + /** The types of what a wire can make that is not in [graph] yet can give. */ + private fun values(graph: StoryGraph): Set = + (graph.variables.mapNotNull { it.type } + StoryOperators.all.flatMap { it.outputs }.mapNotNull { it.kotlin }) + .toSet().filterTo(LinkedHashSet()) { reads("null as $it") } + + /** [code], the script generated from [graph], with what is checked added. Broken Kotlin stays out, not to break the rest. */ + fun script(graph: StoryGraph, code: String): StoryCheckScript { + val plain = graph.dissolved() + val variables = plain.variables.map { value(variableOwner(it.name), "initial", it.type, it.initial) } + val (kept, left) = (variables + plain.nodes.flatMap { nodeFields(plain, it) }).partition { reads(it.code) } + val text = StringBuilder(code.trimEnd()) + val length = text.length + val spans = kept.mapIndexed { index, entry -> + val from = text.append("\n\nsuspend fun storyCheck$index() {\n").length + val start = text.append(entry.frame.before).length + val end = text.append(entry.code).length + val to = text.append(entry.frame.after).append("\n}").length + StoryFieldSpan(entry.field, entry.type?.let(StoryPinType::shortName), from, start, end, to) + } + val pins = StoryGraphCompiler.pins(graph, StoryActionFunctions).filterValues(::reads).entries.withIndex() + .associate { (index, pin) -> + text.append("\n\nsuspend fun storyPinOf$index() {\nval $PIN$index = (\n").append(pin.value) + .append("\n)\n}") + "$PIN$index" to pin.key + } + val wanted = places(plain).withIndex().associate { (index, type) -> "$PLACE$index" to type } + wanted.entries.joinTo(text, "", "\n\nsuspend fun storyPlaces() {\n", "}") { (name, type) -> + "val $name: $type = (null as $type)\n" + } + values(plain).withIndex().joinTo(text, "", "\n\nsuspend fun storyValues() {\n", "}") { (index, type) -> + "val ${PIN}Value$index: $type = (null as $type)\n" + } + val placed = wanted.mapValues { StoryPinType.shortName(it.value) } + return StoryCheckScript(text.toString(), length, spans, left.associate { it.field to it.frame }, pins, placed) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryInsight.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryInsight.kt new file mode 100644 index 000000000..1d1b2a2e6 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryInsight.kt @@ -0,0 +1,254 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinFragmentAnalysis +import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinFragmentContext +import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinFragmentInsight +import ru.hollowhorizon.hollowengine.client.ui.ide.kotlinTokenHighlighter +import ru.hollowhorizon.hollowengine.client.ui.widgets.* + +/** The colors analysis gave the Kotlin [text] of a field. */ +internal class StoryFieldColors(val text: String, val highlights: List) + +/** + * What the Kotlin compiler says of a story. + * + * @param checks what is wrong with its nodes. + * @param fields what it says of its fields, where they are written. + * @param types what the outputs worked out on the spot are, by node and pin. + * @param fits by type of value, the types of the places that can hold it, among [places]. + * @param places the types of what links go into that analysis could tell. + * @param revision moves on with each analysis. + */ +internal class StoryAnalysis( + val checks: List = emptyList(), + val fields: Map> = emptyMap(), + val colors: Map = emptyMap(), + val types: Map, String> = emptyMap(), + val fits: Map> = emptyMap(), + val places: Set = emptySet(), + val revision: Long = 0L, +) { + /** Whether Kotlin lets a value of the type [from] go into a place of the type [into]; what it has not told, it lets. */ + fun takes(from: String, into: String): Boolean { + val place = StoryPinType.shortName(into) + return place !in places || fits[StoryPinType.shortName(from)]?.contains(place) != false + } +} + +/** + * The Kotlin analysis of stories. A story has one file, the script it compiles to with [StoryFieldChecks] added, and + * all that fields and pins need comes out of the analysis of it. Fields keep their highlighter and completions for as + * long as the story is open: a new highlighter would make them start coloring from nothing. + */ +internal object StoryInsight { + private const val SCRIPT = ".kts" + private const val IMPORT = "import " + private const val DECLARATION = "var" + private const val BODY_START = "\nsuspend fun storyField() {\n" + private const val BODY_END = "\n}\n" + private const val TYPE_START = "\nval storyType: " + private const val TYPE_END = "\n" + private val NO_TYPE = setOf("Nothing", "Nothing?") + private val tokens = kotlinTokenHighlighter { StoryKotlin.syntax(it)?.tokens.orEmpty() } + + internal class FieldInsight(val highlighter: UiSyntaxHighlighter, val completions: UiCompletionContributor) + + /** What analysis has of the story in [state], which goes when the file closes. */ + internal class Session(val state: StoryEditorState, path: String) : AutoCloseable { + val script = KotlinFragmentInsight(path + SCRIPT, StoryFieldChecks.PIN, StoryFieldChecks.PLACE) + val fields = HashMap() + var graph: StoryGraph? = null + lateinit var story: GeneratedStory + lateinit var checked: StoryCheckScript + var found: KotlinFragmentAnalysis? = null + + @Volatile + var analysis = StoryAnalysis() + + val types: UiCompletionContributor by lazy { + script.completions({ KotlinFragmentContext(current().story.code + TYPE_START, TYPE_END) }) { + addImport( + this, + it + ) + } + } + + fun of(graph: StoryGraph) = apply { + if (graph == this.graph) return@apply + this.graph = graph + story = StoryGraphCompiler.compile(graph, StoryActionFunctions) + checked = StoryFieldChecks.script(graph, story.code) + } + + fun current() = of(state.read() ?: StoryGraph()) + + override fun close() = script.close() + } + + private fun session(state: StoryEditorState, graph: StoryGraph = state.read() ?: StoryGraph()): Session { + val session = state.insight ?: Session(state, state.context?.file?.path.orEmpty()).also { state.insight = it } + return session.of(graph) + } + + private fun addImport(session: Session, name: String) = + session.state.change { if (name in imports) this else copy(imports = imports + name) } + + /** Colors [field] by what analysis made of it, or by the look of it until analysis gets to what it holds now. */ + private fun colors(session: Session, field: StoryFieldKey) = UiSyntaxHighlighter { text -> + session.analysis.colors[field]?.takeIf { it.text == text }?.highlights ?: tokens.highlight(text) + } + + private fun part(highlights: List, start: Int, end: Int) = + highlights.filter { it.end > start && it.start < end } + .map { it.copy(start = it.start.coerceAtLeast(start) - start, end = it.end.coerceAtMost(end) - start) } + + /** Where the field completes: in its place in the script, or in a frame of its own while it cannot be there. */ + private fun context(session: Session, field: StoryFieldKey): KotlinFragmentContext { + val checked = session.current().checked + checked.span(field) + ?.let { return KotlinFragmentContext(checked.text.substring(0, it.start), checked.text.substring(it.end)) } + val frame = checked.frame(field) + return KotlinFragmentContext( + checked.text + BODY_START + frame?.before.orEmpty(), + frame?.after.orEmpty() + BODY_END + ) + } + + /** Colors the script a story generates, [code] being all of it. */ + fun script(state: StoryEditorState, code: String): UiCodeInsight { + val session = session(state) + val highlighter = UiSyntaxHighlighter { text -> + session.script.analyzed(session.checked.text)?.let { part(it.highlights, 0, code.length) } + ?: tokens.highlight(text) + } + return UiCodeInsight(highlighter = highlighter, revision = session.script.version) + } + + /** What a field of Kotlin holding [text] offers: colors, completion, and what is wrong with it. */ + fun field(state: StoryEditorState, field: StoryFieldKey, text: String): UiCodeInsight { + val session = session(state) + val own = session.fields.getOrPut(field) { + FieldInsight( + colors(session, field), + session.script.completions({ context(session, field) }) { addImport(session, it) }) + } + val analysis = analysis(session) + return UiCodeInsight( + highlighter = own.highlighter, + completions = own.completions, + diagnostics = analysis.fields[field].orEmpty().map { problem -> + problem.copy( + start = problem.start.coerceAtMost(text.length), + end = problem.end.coerceAtMost(text.length) + ) + }, + completionRevision = session.script.version, + highlightRevision = analysis.revision, + ) + } + + /** The type the Kotlin of [field] has to be, when it has one. */ + fun expected(state: StoryEditorState, field: StoryFieldKey): String? = session(state).checked.span(field)?.type + + /** What a field holding a type of Kotlin offers. */ + fun type(state: StoryEditorState): UiCodeInsight { + val session = session(state) + return UiCodeInsight(completions = session.types, completionRevision = session.script.version) + } + + /** What the Kotlin compiler says of the story in [state], as analysis gets to it. */ + fun analysis(state: StoryEditorState, graph: StoryGraph): StoryAnalysis = analysis(session(state, graph)) + + private fun analysis(session: Session): StoryAnalysis { + val found = session.script.analyzed(session.checked.text) ?: return session.analysis + if (found !== session.found) { + session.found = found + session.analysis = analyzed(session, found) + } + return session.analysis + } + + private fun analyzed(session: Session, found: KotlinFragmentAnalysis): StoryAnalysis { + val checks = ArrayList() + val fields = HashMap>() + val checked = session.checked + found.diagnostics.forEach { problem -> + val error = problem.severity == UiTextDiagnosticSeverity.ERROR + val span = checked.at(problem.start) + when { + span != null -> { + ofField(problem, span)?.let { fields.getOrPut(span.field) { ArrayList() } += it } + if (error) checks += StoryCheck( + span.field.owner.takeUnless { it.startsWith("variable:") }, + problem.message + ) + } + + error && problem.start < checked.length -> ofScript(session, problem)?.let { checks += it } + } + } + val colors = checked.spans.associate { span -> + span.field to StoryFieldColors( + checked.text.substring(span.start, span.end), + part(found.highlights, span.start, span.end) + ) + } + val properties = found.properties + val places = checked.places.filterKeys { it in properties.types } + val fits = properties.fits.entries.associate { (value, taken) -> + StoryPinType.shortName(properties.types.getValue(value)) to taken.mapTo(HashSet()) { places.getValue(it) } + } + return StoryAnalysis( + checks, fields, colors, types(session, found), fits, places.values.toSet(), session.analysis.revision + 1, + ) + } + + /** + * [problem] in the terms of the field of [span]. What goes around a field is not its author's, so a warning there + * means nothing, and an error there is about the field as a whole. + */ + private fun ofField(problem: UiTextDiagnostic, span: StoryFieldSpan): UiTextDiagnostic? { + val length = span.end - span.start + val inside = problem.start >= span.start && problem.start <= span.end + return when { + inside -> problem.copy( + start = problem.start - span.start, + end = (problem.end - span.start).coerceIn(problem.start - span.start, length), + fixes = emptyList(), + ) + + problem.severity == UiTextDiagnosticSeverity.ERROR -> problem.copy( + start = 0, + end = length, + fixes = emptyList() + ) + + else -> null + } + } + + /** The pins analysis told the types of; pins left out of this analysis keep theirs until they are back in it. */ + private fun types(session: Session, found: KotlinFragmentAnalysis): Map, String> { + val pins = session.checked.pins + val named = found.properties.types.entries.mapNotNull { (name, type) -> + pins[name]?.let { it to StoryPinType.shortName(type) }?.takeUnless { it.second in NO_TYPE } + } + return session.analysis.types.filterKeys { it !in pins.values } + named + } + + /** A problem of the generated script, which says little while the compiler of graphs has problems of its own. */ + private fun ofScript(session: Session, problem: UiTextDiagnostic): StoryCheck? { + val code = session.checked.text + val start = problem.start.coerceAtMost(code.length) + val line = code.substring(0, start).count { it == '\n' } + 1 + val text = code.substring(code.lastIndexOf('\n', (start - 1).coerceAtLeast(0)) + 1, start) + if (session.story.problems.any { !it.warning }) return null + return session.story.nodeAt(line)?.let { StoryCheck(it, problem.message) } ?: StoryCheck( + null, + problem.message + ).takeIf { text.startsWith(IMPORT) || text.trim() == DECLARATION } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryInspector.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryInspector.kt new file mode 100644 index 000000000..5e7d1a73d --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryInspector.kt @@ -0,0 +1,280 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.renameVariable +import ru.hollowhorizon.hollowengine.addons.narrative.ide.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.inspector.* +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeEditor +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnosticSeverity + +private const val AddIcon = "hollowengine:textures/gui/icons/add.svg" +private const val RemoveIcon = "hollowengine:textures/gui/icons/remove.svg" +private const val CodeIcon = "hollowengine:textures/gui/icons/code_editor.svg" +private const val KotlinLine = 13 +private const val KotlinInset = 28 + +/** + * The inspector of what is selected, or of the story itself when nothing is. Its fields read the file each time they + * are drawn and write every change back. + */ +internal fun storyInspector(state: StoryEditorState, selection: StorySelection?): InspectorTarget { + val node = selection?.let { state.read()?.node(it.node) } + return InspectorTarget( + id = "story-node-$selection", + title = node?.let { StoryNodeKind.of(it).title } ?: storyLang("story"), + styles = listOf(StoryEditorStylesheet), + history = state.context?.history, + content = { if (node == null) StoryProperties(state) else NodeInspector(state, selection) }, + ) +} + +@Composable +private fun NodeInspector(state: StoryEditorState, selection: StorySelection) { + val graph = state.read() ?: return + val node = graph.node(selection.node) ?: return + if (node is StoryNode.Reroute) return Text(storyLang("reroute.help"), tags = listOf("story-muted")) + if (node is StoryNode.Say) FormatHint() + val only = selection.pin?.let(StoryPins::optionIndex) + StoryNodeLayout(graph, state).of(node, StoryPoint(0f, 0f), detailed = true).rows.forEach { row -> + val field = row.field + if (field != null && (only == null || field.key.substringAfterLast('-') == "$only")) { + key(field.key) { FieldRow(state, node.id, "st-${node.id}-${field.key}", row, field) } + } + } +} + +/** What a row of the node [owner] edits, the way the inspector shows it. */ +@Composable +private fun FieldRow(state: StoryEditorState, owner: String, id: String, row: StoryNodeRow, field: StoryRowField) { + val title = row.label ?: field.placeholder + val insight = fieldInsight(state, owner, field) + when { + field.vector != null -> VectorRow(state, id, title, row.hint, field.value, field.vector, field.write) + field.assets.isNotEmpty() -> Column(tags = listOf("insp-field")) { + FieldLabel(title, row.hint) + AssetPathField(field.value, title, id, { it.assets(field.assets) }) { field.write(it) } + } + + else -> { + val key = StoryFieldKey(owner, field.key) + TextRow( + title, + field.value, + id = id, + help = row.hint, + completions = insight.completions, + highlighter = insight.highlighter, + diagnostics = insight.diagnostics, + highlightRevision = insight.highlightRevision, + trailing = when { + field.color -> ({ ColorButton(state, id, field) }) + field.code -> ({ OpenCode(state, key, title, field.value, field.write) }) + else -> null + }, + ) { field.write(it) } + } + } + Problems(field.problem, insight.diagnostics) +} + +/** Opens the field [key] in the full editor. */ +@Composable +private fun OpenCode( + state: StoryEditorState, + key: StoryFieldKey, + title: String, + value: String, + write: (String) -> Unit, +) { + InspectorIconButton(CodeIcon, storyLang("code.open")) { state.codeField = StoryCodeField(key, title, value, write) } +} + +/** A vector with its label, the way a row of the inspector shows it. */ +@Composable +private fun VectorRow( + state: StoryEditorState, + id: String, + title: String, + help: String?, + written: String, + helper: String, + write: (String) -> Unit, +) { + Column(tags = listOf("insp-field")) { + FieldLabel(title, help) + StoryVectorField( + id, written, helper, state.context?.readOnly == true, null, listOf("insp-input"), + Modifier.size(100.percent, 22.px), fontSize = 9f, write = write, + ) + } +} + +@Composable +private fun Problem(problem: String?, warning: Boolean = false) { + problem?.let { Text(it, tags = listOfNotNull("story-inspector-problem", "warning".takeIf { warning })) } +} + +/** Everything wrong with a field: [own], what the editor sees, and [found], what the compiler says of it. */ +@Composable +private fun Problems(own: String?, found: List) { + Problem(own) + found.distinctBy { it.message }.filter { it.message != own }.forEach { + Problem(it.message, warning = it.severity != UiTextDiagnosticSeverity.ERROR) + } +} + +/** The variables and imports of the story, and the Kotlin it was generated into for those who want to see it. */ +@Composable +private fun StoryProperties(state: StoryEditorState) { + val graph = state.read() ?: return + Section(storyLang("variables")) { + graph.variables.forEachIndexed { index, variable -> key(index) { VariableCard(state, graph, index, variable) } } + InspectorButton(storyLang("variable.add"), AddIcon) { + val variable = newVariable(graph) + state.openVariables += variable.name + state.change(StoryUndo.ADD) { copy(variables = variables + variable) } + } + } + StoryImports(state, graph) + val kotlin = StoryGraphCompiler.compile(graph, StoryActionFunctions) + kotlin.problems.forEach { Text(StoryChecks.text(it), tags = listOf("story-inspector-problem")) } + CollapsibleSection( + storyLang("kotlin"), + state.showsKotlin, + id = "story-kotlin-section", + onToggle = { state.showsKotlin = !state.showsKotlin }, + ) { + val code = kotlin.code.trimEnd() + UiCodeEditor( + value = code, + onChange = {}, + insight = StoryInsight.script(state, code), + readOnly = true, + fontSize = 9f, + id = "story-kotlin", + tags = listOf("story-kotlin"), + modifier = Modifier.size(100.percent, (code.lines().size * KotlinLine + KotlinInset).px), + ) + } +} + +/** What the Kotlin of the story imports on top of what every story does; completion adds to it. */ +@Composable +private fun StoryImports(state: StoryEditorState, graph: StoryGraph) { + CollapsibleSection( + "${storyLang("imports")} (${graph.imports.size})", + state.showsImports, + id = "story-imports-section", + onToggle = { state.showsImports = !state.showsImports }, + ) { + graph.imports.forEachIndexed { index, name -> + key(index) { + TextRow( + "", name, id = "st-import-$index", + trailing = { + InspectorIconButton(RemoveIcon, storyLang("remove")) { + state.change(StoryUndo.REMOVE) { copy(imports = imports.filterIndexed { at, _ -> at != index }) } + } + }, + ) { typed -> + state.change(mergeKey = "import/$index") { + copy(imports = imports.mapIndexed { at, it -> if (at == index) typed.trim() else it }) + } + } + } + } + InspectorButton( + storyLang("import.add"), + AddIcon + ) { state.change(StoryUndo.ADD) { copy(imports = imports + "") } } + } +} + +/** A variable as a card of its own, which folds to its name and type. */ +@Composable +private fun VariableCard(state: StoryEditorState, graph: StoryGraph, index: Int, variable: StoryVariable) { + val open = variable.name in state.openVariables + Column(id = "st-variable-card-$index", tags = listOf("story-card")) { + Row( + id = "st-variable-head-$index", + tags = listOf("story-card-head"), + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND).onClick { + state.openVariables = + if (open) state.openVariables - variable.name else state.openVariables + variable.name + }, + ) { + DisclosureArrow(open) + Text(variable.name, tags = listOf("story-card-title")) + Text(variable.type.orEmpty(), tags = listOf("story-card-type"), modifier = Modifier.grow(1f)) + InspectorIconButton(RemoveIcon, storyLang("remove"), id = "st-variable-remove-$index") { + state.change(StoryUndo.REMOVE) { copy(variables = variables - variable) } + } + } + if (open) Column(tags = listOf("story-card-body")) { VariableFields(state, graph, index, variable) } + } +} + +/** A variable: its name, what it starts as, and its type. A name is kept only once it can be one. */ +@Composable +private fun VariableFields(state: StoryEditorState, graph: StoryGraph, index: Int, variable: StoryVariable) { + var draft by remember(variable.name) { mutableStateOf(variable.name) } + val others = graph.variables.filter { it !== variable }.map { it.name } + val wrong = StoryChecks.variableName(draft, others) + TextRow(storyLang("variable.name"), draft, id = "st-variable-name-$index") { typed -> + draft = typed + if (StoryChecks.variableName(typed, others) == null && StoryKotlin.isAvailable) { + state.openVariables = state.openVariables - variable.name + typed + state.change(mergeKey = "variable-name/$index") { renameVariable(variable.name, typed) } + } + } + Problem(wrong) + VariableType(state, index, variable) + VariableInitial(state, index, variable) +} + +/** The Kotlin type of a variable, which `?` lets stay empty. */ +@Composable +private fun VariableType(state: StoryEditorState, index: Int, variable: StoryVariable) { + TextRow( + storyLang("variable.type"), variable.type.orEmpty(), id = "st-variable-type-$index", + completions = StoryInsight.type(state).completions, help = storyLang("variable.type.help"), + ) { typed -> + val type = typed.trim().ifEmpty { null } + state.change(mergeKey = "variable-type/$index") { + copy(variables = variables.map { if (it.name == variable.name) it.copy(type = type) else it }) + } + } +} + +/** What a variable starts as, in Kotlin. */ +@Composable +private fun VariableInitial(state: StoryEditorState, index: Int, variable: StoryVariable) { + val key = StoryFieldKey(StoryFieldChecks.variableOwner(variable.name), "initial") + val insight = StoryInsight.field(state, key, variable.initial) + val write = { typed: String -> + state.change(mergeKey = "variable/$index") { + copy(variables = variables.map { if (it.name == variable.name) it.copy(initial = typed) else it }) + } + } + TextRow( + storyLang("variable.initial"), variable.initial, id = "st-variable-$index", + completions = insight.completions, highlighter = insight.highlighter, diagnostics = insight.diagnostics, + highlightRevision = insight.highlightRevision, help = storyLang("variable.help"), + trailing = { OpenCode(state, key, storyLang("variable.initial"), variable.initial, write) }, + ) { write(it) } + Problems(StoryChecks.variable(variable), insight.diagnostics) +} + +/** What can be written in a line: the markup of text, shown in place of a toolbar. */ +@Composable +private fun FormatHint() = Text(storyLang("phrase.format"), tags = listOf("story-muted", "wrapped")) + +private fun newVariable(graph: StoryGraph): StoryVariable { + val taken = graph.variables.mapTo(HashSet()) { it.name } + val name = generateSequence(1) { it + 1 }.map { "value$it" }.first { it !in taken } + return StoryVariable(name, "0.0", "Double") +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryNodeKind.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryNodeKind.kt new file mode 100644 index 000000000..75b13c29f --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryNodeKind.kt @@ -0,0 +1,48 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode +import ru.hollowhorizon.hollowengine.addons.narrative.graph.resolved +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import ru.hollowhorizon.hollowengine.client.ui.UiColor + +/** + * What a node of a story is called and how it is painted: [tag] is its style, [color] that of its title bar and of its + * icon in the menus, unless its category gives it one. + */ +internal enum class StoryNodeKind(val tag: String, icon: String, name: String, color: Int) { + PHRASE("phrase", "phrase", "phrase", 0x3B8950), + WAIT("wait", "wait", "wait", 0x3E86C4), + CHOICE("choice", "choice", "choice", 0xBF8246), + CONDITION("condition", "condition", "condition", 0x7A5FB5), + START("start", "label", "start", 0xCF5E54), + SET("command", "command", "set", 0x4C5566), + CODE("block", "block", "code", 0x5F59B1), + VALUE("pure", "block", "code", 0x2F7D80), + LOOP("block", "block", "loop", 0x5F59B1), + BLOCK("block", "block", "block", 0x5F59B1), + COMMAND("command", "command", "command", 0x4C5566); + + val icon = storyIcon(icon) + val color = UiColor.fromArgb(0xFF shl 24 or color) + val title: String by lazy { storyLang("kind.$name") } + + companion object { + private const val WAIT_ACTION = "hollowengine-narrative:wait" + + fun of(node: StoryNode): StoryNodeKind = when (node) { + is StoryNode.Start -> START + is StoryNode.Say -> PHRASE + is StoryNode.Choose -> CHOICE + is StoryNode.Branch -> CONDITION + is StoryNode.Assign -> SET + is StoryNode.Code -> CODE + is StoryNode.Expression, is StoryNode.Pure, is StoryNode.Reroute -> VALUE + is StoryNode.Loop -> LOOP + is StoryNode.Call -> when { + StoryActionFunctions.resolved(node).block -> BLOCK + node.action == WAIT_ACTION -> WAIT + else -> COMMAND + } + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryProblems.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryProblems.kt new file mode 100644 index 000000000..5c725c2ab --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryProblems.kt @@ -0,0 +1,32 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdePreviewContext +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdePreviewProblem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnosticSeverity + +/** + * Tells the IDE what is wrong with the story for as long as the editor is shown. + * + * @param reveal shows the node a problem is about. + */ +@Composable +internal fun PublishStoryProblems(context: HollowIdePreviewContext, graph: StoryGraph, checks: List, reveal: (String) -> Unit) { + SideEffect { + context.publishProblems(checks.map { check -> + val severity = if (check.warning) UiTextDiagnosticSeverity.WARNING else UiTextDiagnosticSeverity.ERROR + val owner = check.node?.let(graph::node)?.let(::titleOf) + val message = owner?.let { "$it: ${check.message}" } ?: check.message + HollowIdePreviewProblem(UiTextDiagnostic(0, 0, message, severity)) { check.node?.let(reveal) } + }) + } + DisposableEffect(context) { onDispose { context.publishProblems(emptyList()) } } +} + +private fun titleOf(node: StoryNode): String = + (node as? StoryNode.Call)?.let(StoryCatalog::signatureOf)?.let(StoryCatalog::title) ?: StoryNodeKind.of(node).title diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryVectorField.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryVectorField.kt new file mode 100644 index 000000000..948e3e9f5 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/StoryVectorField.kt @@ -0,0 +1,100 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide + +import androidx.compose.runtime.* +import net.minecraft.client.Minecraft +import net.minecraft.world.phys.Vec3 +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryLiterals +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.style.DefaultUiFontSize +import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import java.math.BigDecimal +import java.math.RoundingMode + +private const val MenuIcon = "hollowengine:textures/gui/icons/graph/arrow.svg" +private const val VectorStylesheet = "hollowengine-narrative:ui/styles/story-graph.hss" +private const val LookReach = 96.0 +private const val Decimals = 2 +private const val BlockHelper = "blockPos" +private val Axes = listOf("X", "Y", "Z") + +/** + * A vector as one field per coordinate, with a menu that fills them from where the author stands and looks. A coordinate + * can be any expression; a vector that is not made of coordinates shows as a single expression. + * + * @param expression the Kotlin of the vector, written by [helper]. + * @param fieldTags the style of its fields. + * @param write gets the Kotlin of the vector. + */ +@Composable +internal fun StoryVectorField( + id: String, + expression: String, + helper: String, + readOnly: Boolean, + problem: String?, + fieldTags: List, + modifier: Modifier = Modifier, + fontSize: Float? = null, + fieldModifier: Modifier = Modifier, + write: (String) -> Unit, +) { + val parts = StoryLiterals.vectorParts(expression, helper) + val tags = fieldTags + listOfNotNull("error".takeIf { problem != null }) + var anchor by remember { mutableStateOf(UiRect.Zero) } + var open by remember { mutableStateOf(false) } + + @Composable + fun Field(value: String, name: String, placeholder: String, change: (String) -> Unit) = TextField( + value = value, + onChange = change, + readOnly = readOnly, + placeholder = placeholder, + fontSize = fontSize ?: DefaultUiFontSize, + id = "$id-$name", + tags = tags, + modifier = fieldModifier.size(0.px, 20.px).grow(1f).tooltipOnHover(problem.orEmpty()), + ) + + Row(tags = listOf("st-vector"), modifier = modifier.style(VectorStylesheet).alignItems(vertical = UiAlign.CENTER)) { + if (parts == null) Field(expression, "expression", "", write) + else parts.forEachIndexed { index, part -> + Field(part, Axes[index].lowercase(), Axes[index]) { typed -> + write(StoryLiterals.vector(parts.mapIndexed { at, it -> if (at == index) typed else it }, helper)) + } + } + Image( + MenuIcon, + id = "$id-menu", + tags = listOf("st-row-button", "st-vector-menu"), + modifier = Modifier.input(hoverable = true, clickable = true).onShown { anchor = it }.onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT && !readOnly) open = true + event.consume() + }, + ) + } + if (open) ContextMenu("$id-popup", anchor, vectorItems(helper, write), onExpandedChange = { open = it }) +} + +/** What can be put in a vector at once: where the author is now, or where the player of the story will be. */ +private fun vectorItems(helper: String, write: (String) -> Unit): List { + val player = Minecraft.getInstance().player + fun place(name: String, at: () -> Vec3?) = UiDropdownItem(storyLang("vector.$name"), enabled = player != null) { + at()?.let { write(StoryLiterals.vector(coordinates(it, helper), helper)) } + } + val live = if (helper == BlockHelper) "storyPlayer().blockPosition()" else "storyPlayer().position()" + return listOf( + place("position") { player?.position() }, + place("look") { player?.pick(LookReach, 0f, false)?.location }, + UiDropdownItem(storyLang("vector.story_player"), separatorBefore = true) { write(live) }, + UiDropdownItem(storyLang("vector.zero")) { write(StoryLiterals.vector(listOf("0", "0", "0"), helper)) }, + ) +} + +private fun coordinates(at: Vec3, helper: String): List = listOf(at.x, at.y, at.z).map { value -> + if (helper == BlockHelper) Math.floor(value).toInt().toString() + else BigDecimal(value).setScale(Decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryFieldButtons.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryFieldButtons.kt new file mode 100644 index 000000000..3f23f3c55 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryFieldButtons.kt @@ -0,0 +1,75 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide.graph + +import androidx.compose.runtime.Composable +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryLiterals +import ru.hollowhorizon.hollowengine.addons.narrative.ide.* +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphEditorController +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphMenu +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover + +private const val CodeIcon = "hollowengine:textures/gui/icons/code_editor.svg" +private const val ArrowIcon = "hollowengine:textures/gui/icons/arrow.svg" + +/** Opens [field] of [owner] in the full editor. */ +@Composable +internal fun CodeEditorButton(state: StoryEditorState, owner: String, title: String, field: StoryRowField) { + Image( + CodeIcon, + id = "st-code-$owner-${field.key}", + tags = listOf("st-row-button"), + modifier = Modifier.input(hoverable = true, clickable = true).tooltipOnHover(storyLang("code.open")) + .onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + state.codeField = StoryCodeField(StoryFieldKey(owner, field.key), title, field.value, field.write) + } + event.consume() + }, + ) +} + +/** Picks which of the known values the list of [field] holds, staying open for as many as are wanted. */ +@Composable +internal fun PicksButton(controller: GraphEditorController, id: String, field: StoryRowField) { + fun open(x: Float, y: Float, held: List) { + controller.menu = GraphMenu(x, y, field.picks.map { value -> + UiDropdownItem(value, mark = UiDropdownMark.CHECKBOX, checked = value in held, closeOnClick = false) { + val next = if (value in held) held - value else held + value + field.write(StoryLiterals.list(next)) + open(x, y, next) + } + }) + } + Box( + id = "st-picks-$id", + tags = listOf("st-picks"), + modifier = Modifier.input(hoverable = true, clickable = true).tooltipOnHover(storyLang("picks.open")) + .onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) open( + event.x, + event.y, + StoryLiterals.listed(field.value) + ) + event.consume() + }, + ) { + Image(ArrowIcon, tags = listOf("st-picks-arrow")) + } +} + +/** Shows the color of [field] and opens the picker of it. */ +@Composable +internal fun ColorButton(state: StoryEditorState, id: String, field: StoryRowField) { + Box( + id = "st-color-$id", + tags = listOf("st-color"), + modifier = Modifier.background(storyColor(field.value)).input(hoverable = true, clickable = true) + .tooltipOnHover(storyLang("color.pick")).onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) state.colorField = field + event.consume() + }, + ) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryGraphEditorModel.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryGraphEditorModel.kt new file mode 100644 index 000000000..737e1c218 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryGraphEditorModel.kt @@ -0,0 +1,437 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide.graph + +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.copyNodes +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.freeStartName +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.link +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.linkValue +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeNodes +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.startNames +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.withGroup +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.withGroupChanged +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.withMembership +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.withoutGroup +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryCatalog +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryCategories +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryEditorState +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryInsight +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryNodeKind +import ru.hollowhorizon.hollowengine.addons.narrative.ide.StoryUndo +import ru.hollowhorizon.hollowengine.addons.narrative.ide.storyLang +import ru.hollowhorizon.hollowengine.client.history.UndoHistory +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.graph.* +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem + +/** + * The boxes of one analyzed graph, and what can be read off them wherever they stand. A node that stays where it was + * keeps its box, so a drag recomposes only the nodes it moves. + */ +internal class StoryGraphBoxes(private val graph: StoryGraph, state: StoryEditorState) { + private val layout = StoryNodeLayout(graph, state) + private val boxes = HashMap() + + fun at(node: StoryNode, place: StoryPoint): StoryNodeBox = + boxes[node.id]?.takeIf { it.x == place.x && it.y == place.y } + ?: layout.of(node, place).also { boxes[node.id] = it } + + /** The box [node] gets on its own, for one not in the graph yet; what its pins carry can be asked from then on. */ + fun of(node: StoryNode): StoryNodeBox = layout.of(node, StoryPoint(0f, 0f)).also { boxes[node.id] = it } + + fun typeOf(pin: GraphPin): StoryPinType { + val box = boxes[pin.node] ?: return StoryPinType.ANY + val node = box.node + if (node is StoryNode.Reroute && !node.flow) return carried(node)?.type ?: StoryPinType.ANY + return box.type(pin) ?: StoryPinType.ANY + } + + /** The exact type of what [pin] carries, when that is known. */ + fun kotlinOf(pin: GraphPin): String? { + val box = boxes[pin.node] ?: return null + val node = box.node + return if (node is StoryNode.Reroute && !node.flow) carried(node)?.kotlin else box.kotlin(pin) + } + + /** The pin the value passing through [reroute] comes from. */ + private fun carried(reroute: StoryNode.Reroute): StoryPinSpec? { + val seen = HashSet() + var at = reroute.id + while (seen.add(at)) { + val link = graph.index.valuesInto(at).firstOrNull() ?: return null + val box = boxes[link.from] ?: return null + if (box.node !is StoryNode.Reroute) return box.rows.firstNotNullOfOrNull { row -> row.output?.takeIf { it.name == link.fromPin } } + at = link.from + } + return null + } + + fun isLinked(pin: GraphPin): Boolean = graph.links.any { + if (pin.output) it.from == pin.node && it.fromPin == pin.name else it.to == pin.node && it.toPin == pin.name + } + + fun tooltip(pin: GraphPin): String = kotlinOf(pin) ?: typeOf(pin).let { type -> + when (type) { + StoryPinType.FLOW -> storyLang("pin.flow") + StoryPinType.ANY, StoryPinType.OBJECT -> storyLang("pin.any") + else -> type.label + } + } +} + +/** A story as [GraphEditorController] sees it, made anew for each graph drawn. */ +internal class StoryGraphEditorModel( + private val state: StoryGraphState, + private val graph: StoryGraph, + private val layout: StoryGraphBoxes, + private val places: Map, + private val select: (GraphSelection) -> Unit, +) : GraphEditorModel { + /** Every node, those a collapsed group hides too. */ + val all: Map = + graph.nodes.mapNotNull { node -> places[node.id]?.let { node.id to layout.at(node, it) } }.toMap() + + private val hidden = graph.groups.filter { it.collapsed }.flatMap { it.nodes }.toSet() + val boxes: Map = all.filterKeys { it !in hidden } + + override val scene: GraphEditorScene = scene() + override val history: UndoHistory? get() = state.context?.history + + private fun scene(): GraphEditorScene { + val frames = ArrayList() + val cards = ArrayList() + graph.groups.forEach { group -> + val members = group.nodes.filter { it in all }.toSet() + val around = GraphRect.around(members.map { all.getValue(it).rect }) ?: return@forEach + val title = group.title.ifBlank { graphEditorText("group") } + val color = GraphGroupColors.of(group.color) + if (group.collapsed) { + val entering = + graph.links.filter { it.to in members && it.from !in members }.map { it.to to it.toPin }.distinct() + val leaving = + graph.links.filter { it.from in members && it.to !in members }.map { it.from to it.fromPin } + .distinct() + cards += GraphCardView.of(group.id, title, color, around, entering, leaving) + } else { + val frame = around.expanded(GROUP_PADDING, GROUP_HEADER + GROUP_PADDING, GROUP_PADDING, GROUP_PADDING) + frames += GraphFrameView(group.id, title, color, frame) + } + } + val pins = boxes.values.flatMap { it.pins } + cards.flatMap { it.pins } + val byName = pins.reversed().associateBy { Triple(it.node, it.name, it.output) } + val links = graph.links.mapNotNull { link -> + val from = byName[Triple(link.from, link.fromPin, true)] ?: return@mapNotNull null + val to = byName[Triple(link.to, link.toPin, false)] ?: return@mapNotNull null + GraphLinkView(link, from, to, pinColor(from)) + } + val reroutes = boxes.filterValues { it.node is StoryNode.Reroute }.keys + return GraphEditorScene( + boxes.mapValues { it.value.rect }, + boxes.mapValues { it.value.title }, + pins, + links, + frames, + cards, + reroutes + ) + } + + fun typeOf(pin: GraphPin): StoryPinType = layout.typeOf(pin) + + override fun pinColor(pin: GraphPin): UiColor = typeOf(pin).color() + + override fun beginGesture() { + history?.begin() + } + + /** A drag writes its places to the file only here, since every write parses the story anew. */ + override fun endGesture() { + val moved = state.moved + if (moved.isNotEmpty()) { + state.change(StoryUndo.MOVE) { copy(layout = layout + places + moved) } + state.moved = emptyMap() + } + history?.commit() + } + + override fun cancelGesture() { + state.moved = emptyMap() + history?.rollback() + } + + override fun positionOf(node: String): Pair? = places[node]?.let { it.x to it.y } + + override fun moveNodes(positions: Map>) { + state.moved += positions.mapValues { (_, at) -> StoryPoint(at.first, at.second) } + } + + /** Whether a link can go from [output] to [input], whichever way it gets made. */ + private fun takes(output: GraphPin, input: GraphPin): Boolean { + val type = typeOf(output) + if (!type.fits(typeOf(input))) return false + if (type.converts) return true + val from = layout.kotlinOf(output) ?: return true + val to = layout.kotlinOf(input) ?: return true + return StoryInsight.analysis(state, graph).takes(from, to) + } + + override fun pinTooltip(pin: GraphPin): String = layout.tooltip(pin) + + /** A node is found by its title and by what its fields hold, also while it is collapsed. */ + override fun searchText(node: String): String { + val box = all[node] ?: return "" + return (listOf(box.title) + box.node.texts()).joinToString("\n") + } + + override fun connect(from: GraphPin, to: GraphPin) { + val output = if (from.output) from else to + val input = if (from.output) to else from + if (!takes(output, input)) return + if (typeOf(output) == StoryPinType.FLOW) { + state.change(StoryUndo.LINK) { link(output.node, output.name, input.node) } + } else if (!readsFrom(output.node, input.node)) { + state.change(StoryUndo.LINK) { linkValue(output.node, output.name, input.node, input.name) } + } + } + + /** Whether the value of [node] is worked out from that of [target]: a link from it into [target] would be a cycle. */ + private fun readsFrom(node: String, target: String): Boolean { + val seen = HashSet() + val queue = ArrayDeque(listOf(node)) + while (queue.isNotEmpty()) { + val at = queue.removeFirst() + if (at == target) return true + if (seen.add(at)) queue += graph.index.valuesInto(at).map { it.from } + } + return false + } + + override fun connectToNode(pending: GraphPendingLink, node: String) { + val own = pinOf(pending) ?: return + val type = typeOf(own) + val box = boxes[node] ?: return + val target = box.pins.firstOrNull { pin -> + pin.output != pending.fromOutput && (if (pin.output) takes(pin, own) else takes(own, pin)) && (pin.output || type == StoryPinType.FLOW || graph.links.none { it.to == node && it.toPin == pin.name }) + } ?: return + connect(own, target) + } + + private fun pinOf(pending: GraphPendingLink) = + scene.pins.firstOrNull { it.node == pending.node && it.name == pending.pin && it.output == pending.fromOutput } + + /** A value input gives its link up to be dropped elsewhere; a flow input only the link it has alone. */ + override fun detach(input: GraphPin): GraphPin? { + val into = graph.links.filter { it.to == input.node && it.toPin == input.name } + val link = into.singleOrNull() ?: return null + state.change(StoryUndo.REMOVE) { copy(links = links - link) } + return scene.pins.firstOrNull { it.node == link.from && it.output && it.name == link.fromPin } + } + + private fun add(node: StoryNode, x: Float, y: Float, then: StoryGraph.() -> StoryGraph = { this }) { + state.change(StoryUndo.ADD) { + copy( + nodes = nodes + node, layout = layout + (node.id to StoryPoint(x, y)) + ).then() + } + select(GraphSelection.of(node.id)) + } + + private fun boxFor(node: StoryNode) = layout.of(node) + + /** Links [pending] to the first pin of the new [node] that takes it. */ + private fun StoryGraph.linked(pending: GraphPendingLink, type: StoryPinType, node: StoryNode): StoryGraph { + val own = pinOf(pending) ?: return this + val target = boxFor(node).pins.firstOrNull { pin -> + pin.output != pending.fromOutput && (if (pin.output) takes(pin, own) else takes(own, pin)) + } ?: return this + return when { + type == StoryPinType.FLOW && pending.fromOutput -> link(pending.node, pending.pin, node.id) + type == StoryPinType.FLOW -> link(node.id, target.name, pending.node) + pending.fromOutput -> linkValue(pending.node, pending.pin, node.id, target.name) + else -> linkValue(node.id, target.name, pending.node, pending.pin) + } + } + + override fun offerNodes(pending: GraphPendingLink, x: Float, y: Float): List { + val type = pinOf(pending)?.let(::typeOf) ?: return emptyList() + val half = GraphNodeMetrics.REROUTE / 2f + val reroute = graphRerouteItem { + val node = StoryNode.Reroute(StoryGraph.newId(), type == StoryPinType.FLOW) + add(node, x - half, y - half) { linked(pending, type, node) } + } + + fun placed(node: StoryNode) { + val left = if (pending.fromOutput) x else x - boxFor(node).width + add(node, left, y - GraphNodeMetrics.HEADER) { linked(pending, type, node) } + } + + val items = + if (type == StoryPinType.FLOW) addStepItems(::placed) else variableItems(::placed) + StoryCatalog.operatorItems( + accepts = { operator -> + if (pending.fromOutput) operator.inputs.any { type.fits(it.type) } else operator.outputs.any { + it.type.fits( + type + ) + } + }, + add = ::placed, + ) + return listOf(reroute) + items.mapIndexed { index, item -> if (index == 0) item.copy(separatorBefore = true) else item } + } + + override fun isLinked(node: String): Boolean = graph.links.any { it.from == node || it.to == node } + + override fun insertIntoLink(link: Any, node: String) { + link as StoryLink + val box = all[node] ?: return + val pins = box.pins + if (link.isFlow) { + val output = pins.firstOrNull { it.output && box.type(it) == StoryPinType.FLOW } ?: return + if (pins.none { !it.output && it.name == StoryPins.IN }) return + state.change(StoryUndo.LINK) { + copy(links = links - link).link(link.from, link.fromPin, node).link(node, output.name, link.to) + } + return + } + val from = GraphPin(link.from, link.fromPin, true, 0f, 0f) + val into = GraphPin(link.to, link.toPin, false, 0f, 0f) + val input = pins.firstOrNull { !it.output && takes(from, it) } ?: return + val output = pins.firstOrNull { it.output && takes(it, into) } ?: return + state.change(StoryUndo.LINK) { + copy(links = links - link).linkValue(link.from, link.fromPin, node, input.name) + .linkValue(node, output.name, link.to, link.toPin) + } + } + + override fun removeLink(link: Any) = state.change(StoryUndo.REMOVE) { copy(links = links - (link as StoryLink)) } + + override fun addReroute(link: Any, x: Float, y: Float): GraphSelection { + link as StoryLink + val node = StoryNode.Reroute(StoryGraph.newId(), link.isFlow) + val half = GraphNodeMetrics.REROUTE / 2f + state.change(StoryUndo.ADD) { + copy( + nodes = nodes + node, + layout = layout + (node.id to StoryPoint(x - half, y - half)), + links = links - link + link.copy(to = node.id, toPin = node.input) + StoryLink( + node.id, node.output, link.to, link.toPin + ), + ) + } + return GraphSelection.of(node.id) + } + + override fun removeNodes(nodes: Set) = state.change(StoryUndo.REMOVE) { removeNodes(nodes) } + + override fun duplicate(nodes: Set): Set { + var fresh = emptyMap() + state.change(StoryUndo.DUPLICATE) { + val placed = copy(layout = layout + places.filterKeys { it in nodes }) + val (next, copies) = placed.copyNodes(nodes, StoryPoint(COPY_OFFSET, COPY_OFFSET)) + fresh = copies + next + } + return fresh.values.toSet() + } + + override fun groupMembers(group: String): Set = + graph.groups.firstOrNull { it.id == group }?.nodes?.filter { it in all }?.toSet().orEmpty() + + override fun isGrouped(node: String): Boolean = graph.groups.any { node in it.nodes } + + override fun groupNodes(nodes: Set): GraphSelection? { + if (nodes.isEmpty()) return null + var id = "" + state.change(StoryUndo.EDIT) { + val (next, made) = withGroup(nodes, graphEditorText("group")) + id = made + next + } + return GraphSelection(nodes, group = id) + } + + override fun ungroup(selection: GraphSelection): GraphSelection? { + val found = + selection.group?.let(::listOf) ?: graph.groups.filter { group -> selection.nodes.any { it in group.nodes } } + .map { it.id } + if (found.isEmpty()) return null + state.change(StoryUndo.EDIT) { found.fold(this) { graph, id -> graph.withoutGroup(id) } } + return GraphSelection(selection.nodes) + } + + override fun setMembership(nodes: Set, group: String?) = + state.change(StoryUndo.EDIT) { withMembership(nodes, group) } + + override fun removeGroup(group: String) = state.change(StoryUndo.REMOVE) { withoutGroup(group) } + + override fun toggleGroup(group: String) = + state.change(StoryUndo.EDIT) { withGroupChanged(group) { it.copy(collapsed = !it.collapsed) } } + + override fun isCollapsed(group: String): Boolean = graph.groups.any { it.id == group && it.collapsed } + + override fun addItems(x: Float, y: Float): List { + fun placed(node: StoryNode) = add(node, x, y) + return addStepItems(::placed) + UiDropdownItem( + storyLang("add.values"), + icon = StoryNodeKind.VALUE.icon, + iconColor = StoryCategories.tint(StoryNodeKind.VALUE.color), + children = StoryCatalog.operatorItems(add = ::placed), + ).let(::listOf) + } + + /** What can be added to the flow. */ + private fun addStepItems(add: (StoryNode) -> Unit): List = buildList { + add(StoryCatalog.kindItem(storyLang("add.phrase"), StoryNodeKind.PHRASE) { add(StoryNode.Say(StoryGraph.newId())) }) + add(StoryCatalog.kindItem(storyLang("add.choice"), StoryNodeKind.CHOICE) { + add(StoryNode.Choose(StoryGraph.newId(), listOf(StoryOption(storyLang("new.option"))))) + }) + add(StoryCatalog.kindItem(storyLang("add.condition"), StoryNodeKind.CONDITION) { + add(StoryNode.Branch(StoryGraph.newId())) + }) + add(StoryCatalog.kindItem(storyLang("add.loop"), StoryNodeKind.LOOP) { add(StoryNode.Loop(StoryGraph.newId())) }) + add(StoryCatalog.kindItem(storyLang("add.start"), StoryNodeKind.START) { + add(StoryNode.Start(StoryGraph.newId(), freeStartName(graph.startNames()))) + }) + addAll(variableItems(add)) + addAll(StoryCatalog.items(add)) + } + + /** Reading and writing the variables of the story. */ + private fun variableItems(add: (StoryNode) -> Unit): List { + if (graph.variables.isEmpty()) return emptyList() + val heading = StoryCategories.PREFIX + "variables" + return listOf(UiDropdownItem( + storyLang("add.variable"), + icon = StoryCategories.icon(heading), + iconColor = StoryCategories.tint(StoryCategories.color(heading)), + children = graph.variables.flatMap { variable -> + listOf( + StoryCatalog.kindItem(storyLang("add.variable.read").format(variable.name), StoryNodeKind.VALUE) { + add(StoryNode.Expression(StoryGraph.newId(), variable.name)) + }, + StoryCatalog.kindItem(storyLang("add.variable.write").format(variable.name), StoryNodeKind.SET) { + add(StoryNode.Assign(StoryGraph.newId(), variable.name)) + }, + ) + }, + )) + } + + private companion object { + const val COPY_OFFSET = 24f + } +} + +/** What [this] holds in words, whether its node shows it or is collapsed. */ +private fun StoryNode.texts(): List = when (this) { + is StoryNode.Start -> listOf(name) + is StoryNode.Say -> listOf(speaker, text) + is StoryNode.Call -> arguments.values + groups.values.flatMap { it.arguments.values } + is StoryNode.Choose -> options.flatMap { listOfNotNull(it.text, it.condition) } + is StoryNode.Branch -> listOf(condition) + is StoryNode.Assign -> listOf(variable, value) + is StoryNode.Code -> listOf(code) + is StoryNode.Expression -> listOf(code) + is StoryNode.Pure -> arguments.values.toList() + is StoryNode.Loop -> listOf(items) + is StoryNode.Reroute -> emptyList() +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryGraphPreview.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryGraphPreview.kt new file mode 100644 index 000000000..429b50062 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryGraphPreview.kt @@ -0,0 +1,365 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide.graph + +import androidx.compose.runtime.* +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.withGroupChanged +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNodeGroup +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryPoint +import ru.hollowhorizon.hollowengine.addons.narrative.ide.* +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.graph.* +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdeFilePreview +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdePreviewContext +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdePreviewError +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdeViewMode +import ru.hollowhorizon.hollowengine.client.ui.inspector.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.* + +private const val CanvasId = "story-graph" +private const val StoryGraphStylesheet = "hollowengine-narrative:ui/styles/story-graph.hss" +private const val ColumnStep = 340f +private const val RowStep = 170f +private const val RemoveIcon = "hollowengine:textures/gui/icons/remove.svg" +private const val AddIcon = "hollowengine:textures/gui/icons/add.svg" + +/** The `.story` file as nodes and the links between them. */ +internal val StoryGraphFilePreview = HollowIdeFilePreview( + id = "story-graph", + defaultMode = HollowIdeViewMode.PREVIEW, + icon = "hollowengine:textures/gui/icons/files/story.svg", + title = "hollowengine.gui.ide.story.graph", + matcher = { it.endsWith(".story") }, +) { StoryGraphPreview(it) } + +internal class StoryGraphState : StoryEditorState() { + val editor = GraphEditorState() + + /** How big the canvas is, for bringing a node to its middle. */ + var canvas = UiRect.Zero + + /** Where a drag has taken nodes, until it ends and writes that to the file. */ + var moved by mutableStateOf(emptyMap()) +} + +@Composable +internal fun StoryGraphPreview(context: HollowIdePreviewContext) { + val state = context.state { StoryGraphState() } + state.context = context + val parsed = state.model.read(context.text) + val graph = parsed.getOrNull() ?: return HollowIdePreviewError(parsed.exceptionOrNull()) + val controller = remember(state) { GraphEditorController(CanvasId, state.editor) } + val arranged = remember(graph) { arrange(graph) } + val ownChecks = remember(graph) { StoryChecks.all(graph) } + val analysis = StoryInsight.analysis(state, graph) + val checks = ownChecks + analysis.checks + val problems = remember(checks) { checks.filter { it.node != null }.groupBy { it.node!! } } + state.clips = rememberStoryClips(graph) + val boxes = remember(graph, analysis, state.clips) { StoryGraphBoxes(graph, state) } + val model = StoryGraphEditorModel(state, graph, boxes, arranged + state.moved) { controller.select(it) } + controller.model = model + + PublishStoryProblems(context, graph, checks) { node -> + controller.select(GraphSelection.of(node)) + model.all[node]?.rect?.let { + state.editor.view.centerOn( + it.centerX, + it.centerY, + state.canvas.width, + state.canvas.height + ) + } + } + + PublishStorySelection(context, state, model) + + Box( + mode = UiBoxMode.STACK, + tags = listOf("ide-file-panel", "story-editor-root"), + modifier = Modifier.size(100.percent, 100.percent).style(InspectorStylesheet).style(StoryEditorStylesheet) + .style(GraphNodesStylesheet).style(StoryGraphStylesheet).focusScope().onPlaced { state.canvas = it }, + ) { + GraphEditorCanvas(controller, Modifier.size(100.percent, 100.percent)) { + GraphFrameHeaders(controller) + model.boxes.values.forEach { box -> + key(box.node.id) { + // A node out of sight keeps its key: one taken out of the list makes Compose move every node after it. + if (box.node.id in controller.shown) { + StoryNodeView(controller, boxes, state, box, problems[box.node.id]) + } + } + } + model.scene.cards.forEach { card -> + key("card-${card.id}") { + GraphGroupCard( + controller, card, + pinName = { it.name }, + ownerName = { model.all[it.node]?.title ?: it.node }, + pinTags = { listOf(model.typeOf(it).tag()) }, + ) + } + } + } + state.codeField?.let { StoryCodeDialog(state, it) } + state.colorField?.let { StoryColorDialog(state, it) } + } +} + +/** Keeps the inspector on what is selected; apart from the graph, which a click has no need to make anew. */ +@Composable +private fun PublishStorySelection( + context: HollowIdePreviewContext, + state: StoryGraphState, + model: StoryGraphEditorModel, +) { + val selection = state.editor.selection + val selected = selection.single?.takeIf { selection.group == null && it in model.all } + SideEffect { + if (state.selected?.node != selected) state.selected = selected?.let { StorySelection(it) } + } + PublishInspector(source = "story-graph:${context.file.path}", key = selection.group to selected) { + selection.group?.let { groupInspector(state, it) } ?: storyInspector( + state, + selected?.let { StorySelection(it) }) + } +} + +/** A node on the canvas with a row per thing it holds, each edited in place. */ +@Composable +private fun StoryNodeView( + controller: GraphEditorController, + boxes: StoryGraphBoxes, + state: StoryGraphState, + box: StoryNodeBox, + problems: List?, +) { + val node = box.node + if (node is StoryNode.Reroute) { + val output = box.pins.first { it.output } + return GraphRerouteView(controller, node.id, box.rect, output, listOf(boxes.typeOf(output).tag())) + } + val problem = problems?.map { it.message }?.distinct()?.joinToString("\n") + val warning = problems?.all { it.warning } == true + GraphNodeFrame( + controller, node.id, box.rect, + listOfNotNull( + "gn-node", + box.tag, + "collapsed".takeIf { box.collapsed }, + (if (warning) "warning" else "error").takeIf { problem != null }), + ) { + GraphNodeHeader(node.id, box.title, box.icon, box.width, box.collapsed, problem, box.color, warning) { + state.change { copy(collapsed = if (node.id in collapsed) collapsed - node.id else collapsed + node.id) } + } + if (!box.collapsed) box.rows.forEach { row -> StoryRowView(controller, state, box, row) } + box.pins.forEach { pin -> + GraphPinView( + controller, pin, box.x, box.y, boxes.isLinked(pin), listOf(boxes.typeOf(pin).tag()), boxes.tooltip(pin), + ) + } + } +} + +@Composable +private fun StoryRowView( + controller: GraphEditorController, + state: StoryGraphState, + box: StoryNodeBox, + row: StoryNodeRow, +) { + val readOnly = state.context?.readOnly == true + Row( + tags = listOf("gn-node-row"), + modifier = Modifier.position(0.px, row.top.px).size(box.width.px, row.height.px) + .padding(GraphNodeMetrics.INSET.px, 0.px).alignItems(vertical = UiAlign.CENTER), + ) { + val field = row.field + val insight = field?.let { fieldInsight(state, box.node.id, it) } + val problems = + field?.let { (listOfNotNull(it.problem) + insight!!.diagnostics.map { found -> found.message }).distinct() } + .orEmpty() + val problem = problems.takeIf { it.isNotEmpty() }?.joinToString("\n") + val wrong = + field?.problem != null || insight?.diagnostics?.any { it.severity == UiTextDiagnosticSeverity.ERROR } == true + if (row.label != null && field == null && row.input == null) Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) + row.label?.let { + Text( + it, + tags = listOfNotNull( + "gn-pin-label", + "output".takeIf { row.input == null && field == null }, + "optional".takeIf { row.optional }) + ) + } + row.hint?.let { FieldHelp(it) } + if (field != null) when { + field.choices.isNotEmpty() -> ChoiceField(controller, box, field) + field.vector != null -> StoryVectorField( + "st-field-${box.node.id}-${field.key}", + field.value, + field.vector, + readOnly, + problem, + listOf("gn-field"), + Modifier.size(0.px, 20.px).grow(1f), + fieldModifier = Modifier.graphField(controller, box.node.id), + write = field.write, + ) + + else -> FieldWithAsset(row, field) { + TextField( + value = field.value, + onChange = field.write, + mode = if (field.lines > 1) UiTextFieldMode.MULTI_LINE else UiTextFieldMode.SINGLE_LINE, + readOnly = readOnly, + placeholder = field.placeholder, + insight = insight!!.copy(diagnosticTooltips = false), + id = "st-field-${box.node.id}-${field.key}", + tags = listOfNotNull( + "gn-field", + "gn-expression".takeIf { field.code }, + (if (wrong) "error" else "warning").takeIf { problem != null }), + modifier = Modifier.size(0.px, if (field.lines > 1) (row.height - 6f).px else 20.px).grow(1f) + .tooltipOnHover(problem.orEmpty()).graphField(controller, box.node.id), + ) + } + } + val own = "${box.node.id}-${field?.key}" + if (field != null && field.picks.isNotEmpty()) PicksButton(controller, own, field) + if (field != null && field.color) ColorButton(state, own, field) + if (field != null && field.code && field.vector == null) CodeEditorButton( + state, + box.node.id, + row.label ?: field.placeholder, + field + ) + row.onRemove?.let { remove -> + Image( + RemoveIcon, + id = "st-remove-${box.node.id}-${row.top}", + tags = listOf("st-row-button"), + modifier = Modifier.input(hoverable = true, clickable = true).onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT && !readOnly) remove() + event.consume() + }, + ) + } + row.onAdd?.let { add -> + Row( + id = "st-add-${box.node.id}", + tags = listOf("st-row-add"), + modifier = Modifier.input(hoverable = true, clickable = true).alignItems(vertical = UiAlign.CENTER) + .onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT && !readOnly) add() + event.consume() + }, + ) { + Image(AddIcon, tags = listOf("st-row-button")) + Text(storyLang("choice.add"), tags = listOf("gn-pin-hint")) + } + } + } +} + +/** What the text field of [field] of the node [owner] offers, by what it holds. */ +internal fun fieldInsight(state: StoryEditorState, owner: String, field: StoryRowField): UiCodeInsight { + val analyzed = StoryInsight.field(state, StoryFieldKey(owner, field.key), field.value) + return when { + field.code -> analyzed + field.typeName -> StoryInsight.type(state) + field.hints.isNotEmpty() -> UiCodeInsight(completions = UiCompletionContributor { field.hints }) + else -> UiCodeInsight(diagnostics = analyzed.diagnostics.map { it.copy(start = 0, end = 0) }) + } +} + +/** [content], the field of [field], with the button that picks a file for it when it names one. */ +@Composable +private fun FieldWithAsset(row: StoryNodeRow, field: StoryRowField, content: @Composable () -> Unit) { + content() + if (field.assets.isNotEmpty()) { + AssetPickerButton(row.label.orEmpty(), field.value, { it.assets(field.assets) }, field.write) + } +} + +/** A value out of a known set: a press opens the values, and the field keeps anything else that was typed. */ +@Composable +private fun ChoiceField(controller: GraphEditorController, box: StoryNodeBox, field: StoryRowField) { + Text( + field.value.ifEmpty { "β€”" }, + id = "st-choice-${box.node.id}-${field.key}", + tags = listOf("gn-chip"), + modifier = Modifier.size(0.px, UiLength.Auto).grow(1f).input(hoverable = true, clickable = true) + .onPress { event -> + if (event.button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return@onPress + controller.menu = GraphMenu( + event.x, event.y, + field.choices.map { value -> + UiDropdownItem( + value, + mark = UiDropdownMark.RADIO, + checked = value == field.value + ) { field.write(value) } + }, + ) + event.consume() + }, + ) +} + +/** The inspector of a group: what it is called and how it looks. */ +private fun groupInspector(state: StoryEditorState, id: String): InspectorTarget? { + val group = state.read()?.groups?.firstOrNull { it.id == id } ?: return null + return InspectorTarget( + id = "story-group-$id", + title = graphEditorText("group"), + subtitle = group.id, + history = state.context?.history + ) { + state.read()?.groups?.firstOrNull { it.id == id }?.let { GroupFields(state, it) } + } +} + +@Composable +private fun GroupFields(state: StoryEditorState, group: StoryNodeGroup) { + fun change(update: (StoryNodeGroup) -> StoryNodeGroup) = + state.change(mergeKey = "group/${group.id}") { withGroupChanged(group.id, update) } + Section(graphEditorText("group")) { + TextRow(storyLang("group.title"), group.title, id = "st-group-title-${group.id}") { title -> + change { + it.copy( + title = title + ) + } + } + Label(storyLang("group.color")) + val current = group.color.takeIf { it in GraphGroupColors.all } ?: GraphGroupColors.all.keys.first() + Pills( + GraphGroupColors.all.keys.toList(), + current, + { storyLang("color.$it") }) { color -> change { it.copy(color = color) } } + ToggleRow( + graphEditorText("collapse_group"), + group.collapsed + ) { collapsed -> change { it.copy(collapsed = collapsed) } } + } +} + +/** Every node with its place: as the file says, or by how far flow is from a start for those without one. */ +private fun arrange(graph: StoryGraph): Map { + val depth = HashMap() + val queue = ArrayDeque(graph.nodes.filterIsInstance().map { it.id to 0 }) + while (queue.isNotEmpty()) { + val (id, at) = queue.removeFirst() + if (id in depth) continue + depth[id] = at + graph.index.flowOut(id).forEach { queue += it to at + 1 } + } + val rows = HashMap() + return graph.nodes.associate { node -> + val column = depth[node.id] ?: 0 + val row = rows.merge(column, 1, Int::plus)!! - 1 + node.id to (graph.layout[node.id] ?: StoryPoint(column * ColumnStep, row * RowStep)) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryNodeLayout.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryNodeLayout.kt new file mode 100644 index 000000000..db0a94e2c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/ide/graph/StoryNodeLayout.kt @@ -0,0 +1,624 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.ide.graph + +import ru.hollowhorizon.hollowengine.addons.narrative.graph.* +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.addInput +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeInput +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.removeOption +import ru.hollowhorizon.hollowengine.addons.narrative.ide.* +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActionFunctions +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNodeMetrics +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphPin +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion +import kotlin.math.ceil +import ru.hollowhorizon.hollowengine.client.ui.widgets.ModelClipPreview +import ru.hollowhorizon.hollowengine.common.utils.Spelling + +/** A pin of a row. [kotlin] is the exact type it carries, when [type] alone does not say. */ +internal data class StoryPinSpec(val name: String, val type: StoryPinType, val kotlin: String? = null) + +/** + * The value a row edits in place. + * + * @param lines how many lines of text it shows; more than one makes it a multi-line field. + * @param choices the values it takes, when it is one of a known set. + * @param picks the values a list it holds can be put together from. + * @param hints what it is usually given, to complete as it is typed. + * @param typeName whether it holds the name of a Kotlin type. + * @param color whether it holds a color, which a button next to it picks. + * @param vector the function that writes it when it is a vector; [value] is then its Kotlin. + * @param assets the ends of the paths of the files it can be picked from. + * @param problem what is wrong with what it holds. + * @param write gets what is typed, as the field shows it. + */ +internal class StoryRowField( + val key: String, + val value: String, + val placeholder: String = "", + val lines: Int = 1, + val choices: List = emptyList(), + val picks: List = emptyList(), + val hints: List = emptyList(), + val typeName: Boolean = false, + val color: Boolean = false, + val code: Boolean = false, + val vector: String? = null, + val assets: List = emptyList(), + val problem: String? = null, + val write: (String) -> Unit, +) + +/** + * A row of a node: a label, the field of what it edits, and the pins on its edges. + * + * @param hint what the mark beside the label says. + * @param optional whether what it holds can be left out. + */ +internal class StoryNodeRow( + val top: Float, + val height: Float, + val label: String? = null, + val input: StoryPinSpec? = null, + val output: StoryPinSpec? = null, + val field: StoryRowField? = null, + val onRemove: (() -> Unit)? = null, + val onAdd: (() -> Unit)? = null, + val hint: String? = null, + val optional: Boolean = false, +) + +/** Where everything of one node sits, in graph units from the corner of the node. */ +internal class StoryNodeBox( + val node: StoryNode, + val title: String, + val icon: String?, + val tag: String, + val color: UiColor?, + val x: Float, + val y: Float, + val width: Float, + val height: Float, + val rows: List, + val collapsed: Boolean, +) { + val rect: GraphRect get() = GraphRect(x, y, width, height) + + val pins: List + get() { + if (node is StoryNode.Reroute) return rerouteMiddle(node) + val middle = y + GraphNodeMetrics.COLLAPSED / 2f + return rows.flatMap { row -> + val at = if (collapsed) middle else y + row.top + FLOW_ROW / 2f + listOfNotNull( + row.input?.let { GraphPin(node.id, it.name, false, x, at) }, + row.output?.let { GraphPin(node.id, it.name, true, x + width, at) }, + ) + } + } + + /** A reroute is a dot, and a link goes in and comes out of its middle. */ + private fun rerouteMiddle(reroute: StoryNode.Reroute): List { + val atX = x + width / 2f + val atY = y + height / 2f + return listOf( + GraphPin(node.id, reroute.input, false, atX, atY), + GraphPin(node.id, reroute.output, true, atX, atY) + ) + } + + private fun spec(pin: GraphPin) = + rows.firstNotNullOfOrNull { row -> (if (pin.output) row.output else row.input)?.takeIf { it.name == pin.name } } + + fun type(pin: GraphPin): StoryPinType? = spec(pin)?.type + + /** The exact type of what [pin] carries, when that is known. */ + fun kotlin(pin: GraphPin): String? = spec(pin)?.kotlin + + companion object { + const val FLOW_ROW = GraphNodeMetrics.FIELD_ROW + } +} + +/** The color of the pins of a type and of the links out of them; the pin rules of `story-graph.hss` use the same. */ +internal fun StoryPinType.color(): UiColor = UiColor.fromArgb( + when (this) { + StoryPinType.FLOW -> 0xFFE6E8EE + StoryPinType.TEXT -> 0xFFE89AC0 + StoryPinType.NUMBER -> 0xFF44BC74 + StoryPinType.BOOL -> 0xFFD9605A + StoryPinType.VECTOR -> 0xFFF0CF60 + StoryPinType.ENTITY -> 0xFF4FA8E0 + StoryPinType.NPC -> 0xFF4FD0D0 + StoryPinType.PLAYER -> 0xFF6F8CF0 + StoryPinType.LIST -> 0xFFC9874A + StoryPinType.MAP -> 0xFFB48EE0 + StoryPinType.OBJECT -> 0xFF8FA0B8 + StoryPinType.ANY -> 0xFFA5ABC0 + }.toInt(), +) + +internal fun StoryPinType.tag(): String = "gn-type-${name.lowercase()}" + +/** Lays out the nodes of a story with the fields that edit them in [state]. */ +internal class StoryNodeLayout(private val graph: StoryGraph, private val state: StoryEditorState) { + private val linked = graph.index.values.keys + private val types = StoryInsight.analysis(state, graph).types + + /** + * The box of [node], with its corner at [place]. + * + * @param detailed whether to add the fields too rare to take up room on a node, for the inspector. + */ + fun of(node: StoryNode, place: StoryPoint, detailed: Boolean = false): StoryNodeBox { + if (node is StoryNode.Reroute) return reroute(node, place) + val rows = Rows(detailed) + val width = rows.build(node) + val collapsed = node.id in graph.collapsed + return StoryNodeBox( + node, titleOf(node), iconOf(node), tagOf(node), colorOf(node), place.x, place.y, width, + if (collapsed) GraphNodeMetrics.COLLAPSED else rows.bottom(), rows.result, collapsed, + ) + } + + private fun reroute(node: StoryNode.Reroute, place: StoryPoint): StoryNodeBox { + val size = GraphNodeMetrics.REROUTE + val type = if (node.flow) StoryPinType.FLOW else StoryPinType.ANY + val row = StoryNodeRow( + size / 2f - StoryNodeBox.FLOW_ROW / 2f, size, + input = StoryPinSpec(node.input, type), output = StoryPinSpec(node.output, type), + ) + return StoryNodeBox( + node, + storyLang("reroute"), + null, + "", + null, + place.x, + place.y, + size, + size, + listOf(row), + false + ) + } + + private fun titleOf(node: StoryNode): String = when (node) { + is StoryNode.Call -> StoryCatalog.signatureOf(node)?.let(StoryCatalog::title) + ?: node.function.substringAfterLast('.') + + is StoryNode.Pure -> StoryCatalog.operatorTitle(node.operator) + else -> StoryNodeKind.of(node).title + } + + /** The icon of [node]: that of its command or its category, or else that of its kind. */ + private fun iconOf(node: StoryNode): String = when (node) { + is StoryNode.Call -> StoryCatalog.signatureOf(node)?.let(StoryCatalog::icon) + is StoryNode.Pure -> StoryOperators.find(node.operator)?.let(StoryCatalog::operatorIcon) + else -> null + } ?: StoryNodeKind.of(node).icon + + private fun tagOf(node: StoryNode): String = "st-${StoryNodeKind.of(node).tag}" + + /** The color of [node]: that of its category, or else that of its kind. */ + private fun colorOf(node: StoryNode): UiColor = when (node) { + is StoryNode.Call -> StoryCatalog.signatureOf(node)?.let(StoryCatalog::color) + is StoryNode.Pure -> StoryOperators.find(node.operator)?.let(StoryCatalog::operatorColor) + else -> null + } ?: StoryNodeKind.of(node).color + + private fun edit(node: String, field: String, change: (StoryNode) -> StoryNode) = + state.editNode(node, field, change) + + /** The rows of one node, each under the one before. */ + private inner class Rows(private val detailed: Boolean) { + val result = ArrayList() + private var y = GraphNodeMetrics.HEADER + PADDING + + fun bottom() = y + PADDING + + private fun row( + label: String? = null, + input: StoryPinSpec? = null, + output: StoryPinSpec? = null, + field: StoryRowField? = null, + onRemove: (() -> Unit)? = null, + onAdd: (() -> Unit)? = null, + hint: String? = null, + optional: Boolean = false, + ) { + val height = if (field != null && field.lines > 1) field.lines * LINE + 12f else StoryNodeBox.FLOW_ROW + result += StoryNodeRow(y, height, label, input, output, field, onRemove, onAdd, hint, optional) + y += height + } + + private fun flow(hasInput: Boolean = true, hasOutput: Boolean = true) = row( + input = FLOW_IN.takeIf { hasInput }, + output = FLOW_NEXT.takeIf { hasOutput }, + ) + + private fun exit(pin: String, label: String) = row(label = label, output = StoryPinSpec(pin, StoryPinType.FLOW)) + + /** A value pin with what it holds when nothing is linked to it. */ + private fun value( + node: String, + name: String, + label: String, + type: StoryPinType, + field: StoryRowField?, + hint: String? = null, + optional: Boolean = false, + kotlin: String? = null, + onRemove: (() -> Unit)? = null, + ) { + val free = (node to name) !in linked + row( + label, + StoryPinSpec(name, type, kotlin), + field = field.takeIf { free }, + hint = hint, + optional = optional, + onRemove = onRemove + ) + } + + fun build(node: StoryNode): Float = when (node) { + is StoryNode.Start -> { + row( + output = FLOW_NEXT, + field = StoryRowField("name", node.name, storyLang("start.name")) { typed -> + edit(node.id, "name") { (it as StoryNode.Start).copy(name = typed.trim()) } + }, + ) + GraphNodeMetrics.WIDTH + } + + is StoryNode.Say -> { + flow() + value( + node.id, StoryPins.SPEAKER, storyLang("phrase.speaker"), StoryPinType.TEXT, + StoryRowField("speaker", node.speaker, storyLang("narrator")) { typed -> + edit(node.id, "speaker") { (it as StoryNode.Say).copy(speaker = typed) } + }, + storyLang("phrase.speaker.help"), + ) + value( + node.id, StoryPins.TEXT, storyLang("phrase.text"), StoryPinType.TEXT, + StoryRowField( + "text", + node.text, + storyLang("phrase.text"), + linesOf(node.text, SAY_COLUMNS) + ) { typed -> + edit(node.id, "text") { (it as StoryNode.Say).copy(text = typed) } + }, + storyLang("phrase.text.help"), + ) + WIDE + } + + is StoryNode.Call -> { + call(node) + WIDE + } + + is StoryNode.Choose -> { + flow(hasOutput = false) + node.options.forEachIndexed { index, option -> option(node, index, option) } + row(onAdd = { + state.editNode(node.id, "add-option") { each -> + each as StoryNode.Choose + each.copy(options = each.options + StoryOption(storyLang("new.option"))) + } + }) + WIDE + } + + is StoryNode.Branch -> { + flow(hasOutput = false) + value( + node.id, StoryPins.CONDITION, storyLang("branch.placeholder"), StoryPinType.BOOL, + StoryRowField( + "condition", + node.condition, + "x > 0", + code = true, + problem = codeProblem(node.condition) + ) { typed -> + edit(node.id, "condition") { (it as StoryNode.Branch).copy(condition = typed) } + }, + ) + exit(StoryPins.THEN, storyLang("branch.then")) + exit(StoryPins.ELSE, storyLang("branch.else")) + GraphNodeMetrics.WIDTH + } + + is StoryNode.Assign -> { + val written = graph.variables.firstOrNull { it.name == node.variable }?.type + val type = StoryPinType.ofName(written) + val kotlin = written?.let(StoryPinType::shortName) + flow() + row( + label = storyLang("set.variable"), + field = StoryRowField( + "variable", + node.variable, + choices = graph.variables.map { it.name }) { typed -> + edit(node.id, "variable") { (it as StoryNode.Assign).copy(variable = typed.trim()) } + }, + ) + val helper = StoryLiterals.vectorHelper(type.type) + value( + node.id, StoryPins.VALUE, storyLang("set.value"), type, + StoryRowField( + "value", node.value, "0", code = true, vector = helper, + problem = codeProblem(node.value), + ) { typed -> + edit(node.id, "value") { (it as StoryNode.Assign).copy(value = typed) } + }, + kotlin = kotlin, + ) + row( + storyLang("set.result"), + output = StoryPinSpec(StoryPins.VALUE, type, kotlin), + hint = storyLang("set.result.help") + ) + if (helper != null) WIDE else GraphNodeMetrics.WIDTH + } + + is StoryNode.Loop -> { + flow() + value( + node.id, StoryPins.ITEMS, storyLang("loop.items"), StoryPinType.LIST, + StoryRowField("items", node.items, "listOf(1, 2)", code = true) { typed -> + edit(node.id, "items") { (it as StoryNode.Loop).copy(items = typed) } + }, + ) + exit(StoryPins.BODY, storyLang("loop.body")) + val item = types[node.id to StoryPins.ITEM] + row(storyLang("loop.item"), output = StoryPinSpec(StoryPins.ITEM, StoryPinType.ofName(item), item)) + GraphNodeMetrics.WIDTH + } + + is StoryNode.Code -> { + flow() + node.inputs.forEach { (name, written) -> + value( + node.id, + name, + name, + StoryPinType.ANY, + StoryRowField("in-$name", written, code = true) { typed -> + edit(node.id, "in-$name") { each -> + each as StoryNode.Code + each.copy(inputs = each.inputs + (name to typed)) + } + }) + } + row( + field = StoryRowField( + "code", + node.code, + storyLang("code.text"), + linesOf(node.code, CODE_COLUMNS), + code = true + ) { typed -> + edit(node.id, "code") { (it as StoryNode.Code).copy(code = typed) } + }, + hint = storyLang("code.help"), + ) + val read = graph.links.any { it.from == node.id && it.fromPin == StoryPins.VALUE } + if (read || !graph.index.isInFlow(node.id)) { + val type = types[node.id to StoryPins.VALUE] + row( + storyLang("call.result"), + output = StoryPinSpec(StoryPins.VALUE, StoryPinType.ofName(type), type) + ) + } + node.outputs.forEach { (name, type) -> + row( + name, + output = StoryPinSpec(name, StoryPinType.ofName(type), type) + ) + } + CODE_WIDTH + } + + is StoryNode.Expression -> { + val type = types[node.id to StoryPins.VALUE] + row( + output = StoryPinSpec(StoryPins.VALUE, StoryPinType.ofName(type), type), + field = StoryRowField("code", node.code, storyLang("code.text"), code = true) { typed -> + edit(node.id, "code") { (it as StoryNode.Expression).copy(code = typed) } + }, + ) + GraphNodeMetrics.WIDTH + } + + is StoryNode.Pure -> { + pure(node) + if (StoryOperators.inputsOf(node) + .any { it.type == StoryPinType.VECTOR } + ) WIDE else GraphNodeMetrics.WIDTH + } + + is StoryNode.Reroute -> GraphNodeMetrics.REROUTE + } + + private fun option(node: StoryNode.Choose, index: Int, option: StoryOption) { + fun change(key: String, update: StoryOption.() -> StoryOption) = edit(node.id, key) { each -> + each as StoryNode.Choose + each.copy(options = each.options.mapIndexed { at, it -> if (at == index) it.update() else it }) + } + row( + output = StoryPinSpec(StoryPins.option(index), StoryPinType.FLOW), + field = StoryRowField("option-$index", option.text, storyLang("new.option")) { typed -> + change("option-$index") { copy(text = typed) } + }, + onRemove = { state.change { removeOption(node.id, index) } }.takeIf { node.options.size > 1 }, + ) + if (detailed) row( + storyLang("option.condition"), + field = StoryRowField("condition-$index", option.condition.orEmpty(), code = true) { typed -> + change("condition-$index") { copy(condition = typed.ifBlank { null }) } + }, + hint = storyLang("option.condition.help"), + ) + } + + private fun call(written: StoryNode.Call) { + val node = StoryActionFunctions.resolved(written) + flow() + if (node.block) exit(StoryPins.BODY, storyLang("loop.body")) + val signature = StoryCatalog.signatureOf(node) + val params = signature?.params.orEmpty().filter { detailed || it.group == null } + params.forEach { param -> + val label = StoryCatalog.paramTitle(signature!!, param) + val type = StoryPinType.of(param.type) + val kotlin = param.type?.let(StoryKotlinTypes::name)?.let(StoryPinType::shortName) + value( + node.id, + param.name, + label, + type, + paramField(node, param), + StoryCatalog.paramHint(signature, param), + param.optional, + kotlin + ) + } + val named = params.mapTo(HashSet()) { it.name } + node.arguments.keys.filter { it !in named && signature == null }.forEach { name -> + value( + node.id, + name, + name, + StoryPinType.ANY, + StoryRowField(name, node.arguments[name].orEmpty(), code = true) { typed -> + edit(node.id, name) { each -> + each as StoryNode.Call + each.copy(arguments = each.arguments + (name to typed)) + } + }) + } + if (node.returns != null) row( + storyLang("call.result"), + output = StoryPinSpec( + StoryPins.VALUE, + StoryPinType.ofName(node.returns), + StoryPinType.shortName(node.returns) + ), + ) + } + + private fun paramField(node: StoryNode.Call, param: StoryParam): StoryRowField { + val written = node.argument(param).orEmpty() + val helper = StoryLiterals.vectorHelper(param.type) + val kotlin = StoryLiterals.isKotlin(param.type) + return StoryRowField( + param.name, + if (helper != null || kotlin) written else StoryLiterals.text(written, param.type), + choices = param.suggestions.takeUnless { kotlin }.orEmpty(), + picks = param.suggestions.takeIf { kotlin }.orEmpty(), + color = param.widget == "color", + code = kotlin, + vector = helper, + assets = param.assets, + hints = clipHints(node, param), + problem = StoryChecks.param(param, written) ?: clipProblem(node, param, written), + ) { typed -> + val next = if (helper != null || kotlin) typed else StoryLiterals.expression(typed, param.type) + edit(node.id, param.name) { (it as StoryNode.Call).with(param, next) } + } + } + + /** The clips of the model of the entity [node] acts on, to complete a parameter naming one as it is typed. */ + private fun clipHints(node: StoryNode.Call, param: StoryParam): List { + if (param.meaning != StoryParamMeaning.ANIMATION) return emptyList() + val clips = state.clips.of(node.id) ?: return emptyList() + return clips.names.orEmpty().map { clip -> + UiTextCompletion(label = clip, tail = clips.model, wordChars = CLIP_CHARS, preview = ModelClipPreview(clips.model, clip)) + } + } + + /** What is wrong with the clip [written] names, when the model of the entity [node] acts on is known to lack it. */ + private fun clipProblem(node: StoryNode.Call, param: StoryParam, written: String): String? { + if (param.meaning != StoryParamMeaning.ANIMATION) return null + val clip = StoryLiterals.literal(written, param.type) ?: return null + val clips = state.clips.of(node.id) ?: return null + val names = clips.names ?: return null + if (clip in names) return null + if (names.isEmpty()) return storyLang("problem.animation_none") + val similar = Spelling.similar(clip, names) + return if (similar.isEmpty()) { + storyLang("problem.animation_unknown", clip) + } else { + storyLang("problem.animation_similar", clip, similar.joinToString()) + } + } + + private fun codeProblem(code: String) = code.takeIf { it.isNotBlank() }?.let(StoryChecks::code) + + private fun pure(node: StoryNode.Pure) { + val operator = StoryOperators.find(node.operator) ?: return + val inputs = StoryOperators.inputsOf(node) + inputs.forEachIndexed { index, input -> + val written = node.arguments[input.name].orEmpty() + val helper = StoryLiterals.vectorHelper(input.type.type) + val shown = if (helper != null) written else input.type.text(written) + val label = if (operator.variadic) "${index + 1}" else StoryCatalog.operatorPinTitle(input.name) + val field = StoryRowField( + input.name, + shown, + input.type.text(input.default), + vector = helper, + hints = StoryCatalog.inputHints(input.name), + problem = codeProblem(written), + ) { typed -> + edit(node.id, input.name) { each -> + each as StoryNode.Pure + val next = if (helper != null) typed else input.type.expression(typed) + each.copy(arguments = if (next.isBlank()) each.arguments - input.name else each.arguments + (input.name to next)) + } + } + value( + node.id, input.name, label, input.type, + field.takeUnless { input.type == StoryPinType.ENTITY && input.default.isNotEmpty() && shown.isEmpty() }, + StoryCatalog.operatorPinHint(operator.id, input.name), + onRemove = { + state.change { + removeInput( + node.id, + index + ) + } + }.takeIf { operator.variadic && inputs.size > 1 }, + ) + } + if (operator.variadic) row(onAdd = { state.change { addInput(node.id) } }) + operator.outputs.forEach { output -> + val kotlin = types[node.id to output.name] ?: output.kotlin + val type = kotlin?.let(StoryPinType::ofName) ?: output.type + row(StoryCatalog.operatorPinTitle(output.name), output = StoryPinSpec(output.name, type, kotlin)) + } + } + + private fun linesOf(text: String, columns: Int): Int = + text.split('\n').sumOf { ceil(it.length.coerceAtLeast(1) / columns.toDouble()).toInt() } + .coerceIn(MIN_LINES, MAX_LINES) + } + + private companion object { + const val CLIP_CHARS = "._-:|/" + const val PADDING = 6f + const val WIDE = 264f + const val CODE_WIDTH = 320f + const val LINE = 12f + const val MIN_LINES = 2 + const val MAX_LINES = 10 + const val SAY_COLUMNS = 44 + const val CODE_COLUMNS = 52 + val FLOW_IN = StoryPinSpec(StoryPins.IN, StoryPinType.FLOW) + val FLOW_NEXT = StoryPinSpec(StoryPins.NEXT, StoryPinType.FLOW) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt new file mode 100644 index 000000000..a075ebd8c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcActions.kt @@ -0,0 +1,314 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.npcs + +import kotlinx.serialization.Serializable +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.Action +import ru.hollowhorizon.hollowengine.addons.narrative.story.ActionAlias +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.addons.narrative.story.action +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.coroutines.Ref +import ru.hollowhorizon.hollowengine.common.coroutines.entityRef +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys +import ru.hollowhorizon.hollowengine.common.npcs.actions.Preempted +import ru.hollowhorizon.hollowengine.common.npcs.navigation.* +import ru.hollowhorizon.hollowengine.common.npcs.registry.ref +import ru.hollowhorizon.hollowengine.common.utils.currentServer +import kotlin.math.atan2 +import kotlin.math.sqrt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Moves the NPC toward [target] until it is within [MoveOptions.arrivalDistance], following it while it moves. + * Undone, the NPC is put back where it started; fast-forwarded, it is put next to the target. + */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@Action("move-to-entity") +suspend fun NpcEntity.move(target: Entity, options: MoveOptions = MoveOptions()): MoveResult = + action(this, target, options) { + val start = capture { NpcPlacement.of(this@move) } + undo { start.applyTo(this@move) } + fastForward { arriveAt(target.position()) } + perform { + val reference = with(server ?: currentServer) { target.entityRef } + actions.act(NpcActionKeys.MOVEMENT) { moveToEntity(reference, options) } + } + } + +/** + * Moves the NPC toward the specified entity until the distance becomes less than or equal to the specified value. + * + * @param entity The entity to move toward. + * @param dist The minimum distance to the entity; movement stops when this distance is reached. + * @param speed The NPC's movement speed. + */ +@ActionAlias +suspend fun NpcEntity.move(entity: Entity, dist: Double = 1.5, speed: Double = 1.0): MoveResult = + move(entity, MoveOptions(arrivalDistance = dist, speed = speed)) + +@ActionAlias +suspend fun NpcEntity.move(target: Ref, options: MoveOptions = MoveOptions()): MoveResult = + move(target.resolve(), options) + +fun NpcEntity.startMove(target: Ref, options: MoveOptions = MoveOptions()): NpcAction = + actions.start(NpcActionKeys.MOVEMENT) { moveToEntity(target, options) } + +/** + * Moves the NPC to the specified entity using the default settings. (Can be specified with a space) + * + * @param mob The entity to move to. + */ +@ActionAlias +suspend infix fun NpcEntity.move(mob: Entity): MoveResult = move(entity = mob) + +/** + * Moves the NPC to [pos] until it is within [MoveOptions.arrivalDistance]. Undone, the NPC is put back where it + * started; fast-forwarded, it is put at [pos]. + */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@Action("move-to") +suspend fun NpcEntity.move(pos: Vec3, options: MoveOptions = MoveOptions()): MoveResult = + action(this, pos, options) { + val start = capture { NpcPlacement.of(this@move) } + undo { start.applyTo(this@move) } + fastForward { arriveAt(pos) } + perform { actions.act(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) } } + } + +/** + * Moves the NPC to the specified position until the distance becomes less than or equal to the specified value. + * + * @param pos The position to move to. + * @param dist The minimum distance to the position; movement stops when this distance is reached. + * @param speed The NPC's movement speed. + */ +@ActionAlias +suspend fun NpcEntity.move(pos: Vec3, dist: Double = 1.5, speed: Double = 1.0): MoveResult = + move(pos, MoveOptions(arrivalDistance = dist, speed = speed)) + +fun NpcEntity.startMove(pos: Vec3, options: MoveOptions = MoveOptions()): NpcAction = + actions.start(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) } + +/** + * Moves the NPC to the specified position using the default settings. + * + * @param position The position to move to. + */ +@ActionAlias +suspend infix fun NpcEntity.move(position: Vec3): MoveResult = move(pos = position) + +/** + * Moves the NPC through [points] in turn without stopping at any but the last: each one before it counts as + * passed within [MoveOptions.passDistance], and [MoveOptions.arrivalDistance] is for the last. + */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@Action("move-through") +suspend fun NpcEntity.move(points: List, options: MoveOptions = MoveOptions()): MoveResult = + action(this, points, options) { + require(points.isNotEmpty()) { "A move needs at least one point" } + val start = capture { NpcPlacement.of(this@move) } + undo { start.applyTo(this@move) } + fastForward { arriveAt(points.last()) } + perform { actions.act(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) } } + } + +fun NpcEntity.startMove(points: List, options: MoveOptions = MoveOptions()): NpcAction { + require(points.isNotEmpty()) { "A move needs at least one point" } + return actions.start(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) } +} + +@EditorCategory("hollowengine.gui.ide.story.category.navigation") +@SimpleAction("stop-moving") +fun NpcEntity.stopMoving() { + actions.cancel(NpcActionKeys.MOVEMENT) + navigation.stop() +} + +/** + * Runs several actions of the NPC as one: the channels they use stay held until [block] returns, and losing + * any of them to a higher priority cancels the whole block with a single [Preempted]. + */ +suspend fun NpcEntity.bundle(block: suspend NpcEntity.() -> R): R = actions.perform { block() } + +/** [bundle] that waits for the actions already running on its channels instead of replacing them. */ +suspend fun NpcEntity.queue(block: suspend NpcEntity.() -> R): R = actions.queue { block() } + +/** + * Keeps the NPC out of [zone] on every way it walks, its moves and its goals alike, until [stopAvoiding]. It is + * not saved with the NPC: a script that needs it after a reload adds it again. Undone, the NPC stops avoiding it. + */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@Action("avoid") +suspend fun NpcEntity.avoid(zone: Zone) = action(this, zone) { + undo { stopAvoiding(zone) } + fastForward { addAvoided(zone) } + perform { addAvoided(zone) } +} + +private fun NpcEntity.addAvoided(zone: Zone) { + npcNavigation.avoidedZones += zone + navigation.recomputePath() +} + +fun NpcEntity.stopAvoiding(zone: Zone) { + if (npcNavigation.avoidedZones.remove(zone)) navigation.recomputePath() +} + +fun NpcEntity.stopAvoidingAll() { + if (npcNavigation.avoidedZones.isEmpty()) return + npcNavigation.avoidedZones.clear() + navigation.recomputePath() +} + +/** + * Makes the NPC look at the specified position. Returns value, is npc looked at target, or still rotating head. + * + * @param position The position to look at. + */ +fun NpcEntity.lookAtNow(position: Vec3, maxAngularSpeed: Float = 360f): Boolean = faceTowards(position, maxAngularSpeed) + +fun NpcEntity.startLookingAt( + position: Vec3, + duration: Duration = DEFAULT_LOOK_DURATION, + maxAngularSpeed: Float = 360f, +): NpcAction = actions.start(NpcActionKeys.LOOK) { + rotate({ position }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) +} + +/** Turns the NPC to [position] over [duration]. Undone, it faces where it did before; fast-forwarded, it faces there at once. */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@Action("look-at") +suspend fun NpcEntity.lookAt( + position: Vec3, + duration: Duration = DEFAULT_LOOK_DURATION, + maxAngularSpeed: Float = 360f, +) = action(this, position, duration, maxAngularSpeed) { + val before = capture { NpcRotation.of(this@lookAt) } + undo { before.applyTo(this@lookAt) } + fastForward { faceAt(position) } + perform { + actions.act(NpcActionKeys.LOOK) { + rotate( + { position }, + duration.inWholeMilliseconds, + maxAngularSpeed = maxAngularSpeed + ) + } + } +} + +@ActionAlias +suspend infix fun NpcEntity.lookAt(position: Vec3) { + lookAt(position, DEFAULT_LOOK_DURATION) +} + +/** + * Makes the NPC look at the specified entity. Returns value, is npc looked at target, or still rotating head. + * + * @param entity The entity to look at. + */ +fun NpcEntity.lookAtNow(entity: Entity, maxAngularSpeed: Float = 360f): Boolean = faceTowards(entity, maxAngularSpeed) + +fun NpcEntity.startLookingAt( + target: Ref, + duration: Duration = DEFAULT_LOOK_DURATION, + maxAngularSpeed: Float = 360f, +): NpcAction = actions.start(NpcActionKeys.LOOK) { + rotate({ target.resolve().eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) +} + +/** Turns the NPC to [target] over [duration], following it as it moves; undone and fast-forwarded like [lookAt] at a position. */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@Action("look-at-entity") +suspend fun NpcEntity.lookAt( + target: Entity, + duration: Duration = DEFAULT_LOOK_DURATION, + maxAngularSpeed: Float = 360f, +) = action(this, target, duration, maxAngularSpeed) { + val before = capture { NpcRotation.of(this@lookAt) } + undo { before.applyTo(this@lookAt) } + fastForward { faceAt(target.eyePosition) } + perform { + actions.act(NpcActionKeys.LOOK) { + rotate({ target.eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) + } + } +} + +@ActionAlias +suspend fun NpcEntity.lookAt(target: Ref, duration: Duration, maxAngularSpeed: Float = 360f) = + lookAt(target.resolve(), duration, maxAngularSpeed) + +@ActionAlias +suspend infix fun NpcEntity.lookAt(entity: Entity) { + lookAt(entity, DEFAULT_LOOK_DURATION) +} + +@EditorCategory("hollowengine.gui.ide.story.category.navigation") +@SimpleAction("stop-looking") +fun NpcEntity.clearLookTarget() { + actions.cancel(NpcActionKeys.LOOK) +} + +/** Puts the NPC at [position] at once, as a fast-forwarded move ends. */ +private fun NpcEntity.arriveAt(position: Vec3): MoveResult { + navigation.stop() + place(position, yRot, xRot) + return MoveResult.Arrived +} + +/** + * Puts the NPC at [position], with a body or without one: an NPC whose chunk is unloaded is moved through the + * registry, which keeps it outside chunks, so an undo or a fast-forward need not wait for it. + */ +private fun NpcEntity.place(position: Vec3, yaw: Float, pitch: Float) { + val level = level() as? ServerLevel + if (level == null || ref.isLoaded) teleportTo(position.x, position.y, position.z) + else ref.teleportTo(level, position, yaw, pitch) +} + +/** Turns the NPC to face [target] at once. */ +private fun NpcEntity.faceAt(target: Vec3) { + val dx = target.x - x + val dy = target.y - eyeY + val dz = target.z - z + val yaw = Math.toDegrees(atan2(dz, dx)).toFloat() - 90f + NpcRotation(yaw, yaw, -Math.toDegrees(atan2(dy, sqrt(dx * dx + dz * dz))).toFloat()).applyTo(this) +} + +/** Where an NPC stands and how it faces, which an undone move puts it back to. */ +@Serializable +internal class NpcPlacement(val x: Double, val y: Double, val z: Double, val rotation: NpcRotation) { + fun applyTo(npc: NpcEntity) { + npc.actions.cancel(NpcActionKeys.MOVEMENT) + npc.navigation.stop() + npc.place(Vec3(x, y, z), rotation.body, rotation.pitch) + rotation.applyTo(npc) + } + + companion object { + fun of(npc: NpcEntity) = NpcPlacement(npc.x, npc.y, npc.z, NpcRotation.of(npc)) + } +} + +/** How an NPC faces: its body, its head and its pitch. */ +@Serializable +internal class NpcRotation(val body: Float, val head: Float, val pitch: Float) { + fun applyTo(npc: NpcEntity) { + npc.yRot = body + npc.yBodyRot = body + npc.yHeadRot = head + npc.xRot = pitch + } + + companion object { + fun of(npc: NpcEntity) = NpcRotation(npc.yBodyRot, npc.yHeadRot, npc.xRot) + } +} + +internal val DEFAULT_LOOK_DURATION = 1500.milliseconds diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcControl.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcControl.kt new file mode 100644 index 000000000..e3e608b47 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcControl.kt @@ -0,0 +1,74 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.npcs + +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryException +import ru.hollowhorizon.hollowengine.common.attachments.api.set +import ru.hollowhorizon.hollowengine.common.attachments.components.Model +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorAsset +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorModel +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorNpcId +import ru.hollowhorizon.hollowengine.common.coroutines.entityRef +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.entities.setNameplate +import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveOptions +import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.despawn +import ru.hollowhorizon.hollowengine.common.utils.currentServer +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private const val NPC = "hollowengine.gui.ide.story.category.npc" +private const val NAVIGATION = "hollowengine.gui.ide.story.category.navigation" + +@EditorCategory(NPC) +@SimpleAction("spawn-npc") +suspend fun ServerLevel.spawnNpc( + pos: Vec3, + @EditorNpcId id: String? = null, + name: String = "", + @EditorModel @EditorAsset("gltf", "glb", "geo.json", "fbx", "obj", "bbmodel") model: String = "", +): NpcEntity = npc(pos, id) { + if (model.isNotEmpty()) set(Model(model)) + if (name.isNotEmpty()) setNameplate(name) +} + +@EditorCategory(NPC) +@SimpleAction("despawn-npc") +fun NpcEntity.removeFromWorld() = despawn() + +@EditorCategory(NPC) +@SimpleAction("find-npc") +fun findNpc(@EditorNpcId id: String): NpcEntity = + currentServer.npcs.alive(id) ?: throw StoryException("No live NPC has the id '$id'") + +@EditorCategory(NPC) +@SimpleAction("teleport") +fun teleport(who: Entity, pos: Vec3) = who.teleportTo(pos.x, pos.y, pos.z) + +@EditorCategory(NAVIGATION) +@SimpleAction("start-moving") +fun NpcEntity.beginMoving(pos: Vec3, options: MoveOptions = MoveOptions()) { + startMove(pos, options) +} + +@EditorCategory(NAVIGATION) +@SimpleAction("start-following") +fun NpcEntity.beginFollowing(target: Entity, options: MoveOptions = MoveOptions()) { + startMove(with(server ?: currentServer) { target.entityRef }, options) +} + +@EditorCategory(NAVIGATION) +@SimpleAction("start-looking") +fun NpcEntity.beginLooking(pos: Vec3, duration: Duration = 1.seconds, maxAngularSpeed: Float = 360f) { + startLookingAt(pos, duration, maxAngularSpeed) +} + +@EditorCategory(NAVIGATION) +@SimpleAction("start-watching") +fun NpcEntity.beginWatching(target: Entity, duration: Duration = 1.seconds, maxAngularSpeed: Float = 360f) { + startLookingAt(with(server ?: currentServer) { target.entityRef }, duration, maxAngularSpeed) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt new file mode 100644 index 000000000..fbe3e99ca --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcHands.kt @@ -0,0 +1,210 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.npcs + +import kotlinx.coroutines.delay +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket.Action.* +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.InteractionHand +import net.minecraft.world.InteractionResult +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.BlockHitResult +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.coroutines.Ref +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction +import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys +import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveOptions +import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveResult +import ru.hollowhorizon.hollowengine.common.npcs.navigation.faceTowards +import ru.hollowhorizon.hollowengine.common.npcs.navigation.rotate +import kotlin.time.Duration.Companion.milliseconds + +/** + * Forces an NPC to use a block at the specified position. + * The NPC will move to the block, look at it, and perform the use action. + * + * @param pos The position of the block to be used. + */ +suspend infix fun NpcEntity.useBlock(pos: Vec3): InteractionResult = useBlock(pos, InteractionHand.MAIN_HAND) + +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@SimpleAction("use-block") +suspend fun NpcEntity.useBlock( + pos: Vec3, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, + face: Direction? = null, +): InteractionResult { + require(reach > 0.0) { "Interaction reach must be greater than zero" } + return actions.act(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) } +} + +fun NpcEntity.startUseBlock( + pos: Vec3, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, + face: Direction? = null, +): NpcAction { + require(reach > 0.0) { "Interaction reach must be greater than zero" } + return actions.start(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) } +} + +private suspend fun NpcEntity.useBlockReaching( + pos: Vec3, + hand: InteractionHand, + reach: Double, + face: Direction?, +): InteractionResult { + val target = Vec3.atCenterOf(BlockPos.containing(pos)) + if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return InteractionResult.FAIL + rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) + return useBlockAt(target, hand, face) +} + +fun NpcEntity.useBlockNow( + pos: Vec3, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, + face: Direction? = null, +): InteractionResult { + val blockPos = BlockPos.containing(pos) + val target = Vec3.atCenterOf(blockPos) + if (eyePosition.distanceToSqr(target) > reach * reach) return InteractionResult.FAIL + + return useBlockAt(target, hand, face) +} + +private fun NpcEntity.useBlockAt(target: Vec3, hand: InteractionHand, face: Direction?): InteractionResult { + val blockPos = BlockPos.containing(target) + syncFakePlayer() + val hit = BlockHitResult(target, face ?: interactionFace(target), blockPos, false) + swing(hand) + val result = fakePlayer.gameMode.useItemOn(fakePlayer, level(), fakePlayer.getItemInHand(hand), hand, hit) + setItemInHand(hand, fakePlayer.getItemInHand(hand).copy()) + return result +} + +/** + * Forces the NPC to destroy the block at the specified position. + * The NPC will move to the block, look at it, and destroy it. + * + * @param pos The position of the block to be destroyed. + */ +suspend infix fun NpcEntity.destroyBlock(pos: Vec3): Boolean = destroyBlock(pos, DEFAULT_INTERACTION_REACH) + +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@SimpleAction("destroy-block") +suspend fun NpcEntity.destroyBlock( + pos: Vec3, + reach: Double, + requireCorrectTool: Boolean = true, +): Boolean = actions.act(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) } + +fun NpcEntity.startDestroyBlock( + pos: Vec3, + reach: Double = DEFAULT_INTERACTION_REACH, + requireCorrectTool: Boolean = true, +): NpcAction = actions.start(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) } + +private suspend fun NpcEntity.destroyBlockReaching(pos: Vec3, reach: Double, requireCorrectTool: Boolean): Boolean { + val blockPos = BlockPos.containing(pos) + val target = Vec3.atCenterOf(blockPos) + if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return false + rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) + syncFakePlayer() + val initialState = level().getBlockState(blockPos) + if (initialState.isAir || initialState.getDestroyProgress(fakePlayer, level(), blockPos) <= 0f) return false + if (requireCorrectTool && !fakePlayer.hasCorrectToolForDrops(initialState)) return false + + val face = interactionFace(target) + fakePlayer.gameMode.handleBlockBreakAction(blockPos, START_DESTROY_BLOCK, face, level().maxBuildHeight, 0) + try { + var elapsedTicks = 0 + while (level().getBlockState(blockPos) == initialState) { + fakePlayer.gameMode.tick() + elapsedTicks++ + faceTowards(target) + swing(InteractionHand.MAIN_HAND) + val progress = initialState.getDestroyProgress(fakePlayer, level(), blockPos) + if (progress * (elapsedTicks + 1) >= 1f) { + fakePlayer.gameMode.handleBlockBreakAction( + blockPos, + STOP_DESTROY_BLOCK, + face, + level().maxBuildHeight, + 0, + ) + break + } + delay(TICK_DURATION) + } + return level().getBlockState(blockPos).isAir + } finally { + if (!level().getBlockState(blockPos).isAir) { + fakePlayer.gameMode.handleBlockBreakAction( + blockPos, + ABORT_DESTROY_BLOCK, + face, + level().maxBuildHeight, + 0, + ) + } + level().destroyBlockProgress(fakePlayer.id, blockPos, -1) + setItemInHand(InteractionHand.MAIN_HAND, fakePlayer.mainHandItem.copy()) + } +} + +private fun NpcEntity.interactionFace(target: Vec3): Direction = Direction.getNearest(eyePosition.subtract(target)) + +fun NpcEntity.interactNow( + target: Entity, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, +): InteractionResult { + if (target.level() !== level() || distanceToSqr(target) > reach * reach) return InteractionResult.FAIL + syncFakePlayer() + val result = fakePlayer.interactOn(target, hand) + setItemInHand(hand, fakePlayer.getItemInHand(hand).copy()) + swing(hand) + return result +} + +fun NpcEntity.startInteract( + target: Ref, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, +): NpcAction = actions.start(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) } + +private suspend fun NpcEntity.interactReaching( + target: Ref, + hand: InteractionHand, + reach: Double, +): InteractionResult { + if (move(target, MoveOptions(arrivalDistance = reach)) != MoveResult.Arrived) return InteractionResult.FAIL + val entity = target.resolve() + rotate({ entity.eyePosition }, DEFAULT_LOOK_DURATION.inWholeMilliseconds) + return interactNow(entity, hand, reach) +} + +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@SimpleAction("interact") +suspend fun NpcEntity.interact( + target: Ref, + hand: InteractionHand = InteractionHand.MAIN_HAND, + reach: Double = DEFAULT_INTERACTION_REACH, +): InteractionResult = actions.act(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) } + +private fun NpcEntity.syncFakePlayer() { + val level = level() as ServerLevel + fakePlayer.gameMode.setLevel(level) + fakePlayer.moveTo(x, y, z, yRot, xRot) + InteractionHand.entries.forEach { currentHand -> + fakePlayer.setItemInHand(currentHand, getItemInHand(currentHand).copy()) + } +} + +private val TICK_DURATION = 50.milliseconds +private const val DEFAULT_INTERACTION_REACH = 3.0 diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcSpawning.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcSpawning.kt new file mode 100644 index 000000000..93501d2d9 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/npcs/NpcSpawning.kt @@ -0,0 +1,70 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.npcs + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.EntityType +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.Action +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryException +import ru.hollowhorizon.hollowengine.addons.narrative.story.Unsaved +import ru.hollowhorizon.hollowengine.addons.narrative.story.action +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.buildNpc +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForUuid +import java.util.* +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorHidden + +/** + * Spawns an NPC at [pos], set up by [builder]. Undone, the NPC is removed for good, wherever it went since; brought + * back by a rewind, it returns as [builder] made it, since the journal keeps the NPC it built rather than [builder]. + * + * @param id the author's id, such as `"village:blacksmith"`. When a live NPC has it, that NPC is returned, [builder] + * does not run, and undoing the call leaves that NPC where it is. + */ +@EditorCategory("hollowengine.gui.ide.story.category.npc") +@EditorHidden +@Action("npc") +suspend fun ServerLevel.npc(pos: Vec3, id: String? = null, @Unsaved builder: NpcEntity.() -> Unit = {}): NpcEntity = + action(this, pos, id, builder) { + val npcs = server.npcs + val built = capture { if (id != null && npcs.alive(id) != null) null else BuiltNpc.of(buildNpc(pos, builder)) } + undo { built?.let { npcs.despawn(it.uuid) } } + perform { + if (built == null) return@perform npcs.alive(id!!) ?: throw StoryException("NPC '$id' is gone") + npcs.resolve(built.uuid) ?: built.spawn(this@npc, id) + } + } + +/** An NPC as built for [npc], kept by the journal to bring it back exactly so. */ +@Serializable +internal class BuiltNpc( + @Serializable(ForUuid::class) val uuid: UUID, + @Serializable(ForCompoundNBT::class) val nbt: CompoundTag, +) { + /** The instance just built, spawned as it is the first time; the journal reads only [nbt] back. */ + @Transient + private var built: NpcEntity? = null + + fun spawn(level: ServerLevel, id: String?): NpcEntity { + val npc = built ?: EntityType.loadEntityRecursive(nbt.copy(), level) { it } as? NpcEntity + ?: throw StoryException("NPC $uuid cannot be built again from what the story kept") + built = null + level.server.npcs.spawn(npc, id) + return npc + } + + companion object { + fun of(npc: NpcEntity): BuiltNpc { + val nbt = npc.saveWithoutId(CompoundTag()) + nbt.putString(Entity.ID_TAG, BuiltInRegistries.ENTITY_TYPE.getKey(npc.type).toString()) + return BuiltNpc(npc.uuid, nbt).also { it.built = npc } + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/ChoiceOverlay.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/ChoiceOverlay.kt new file mode 100644 index 000000000..abb93cb8b --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/ChoiceOverlay.kt @@ -0,0 +1,78 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.poll + +import kotlinx.serialization.Serializable +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.common.data.dataKey +import ru.hollowhorizon.hollowengine.common.ui.net.UiSession +import ru.hollowhorizon.hollowengine.common.ui.net.showOverlay +import ru.hollowhorizon.hollowengine.common.utils.rl + +/** + * What [ChoiceOverlay] writes into its UI session and reads back. An overlay a `.ui.kts` declares with + * [ChoiceOverlay.ID] replaces the built-in one and speaks the same keys. + */ +object ChoiceUiKeys { + val Choice = dataKey("choice") { ChoiceView() } + + /** `send { putString(Action, Vote); putInt(Index, index) }` votes for the option at that index. */ + const val Action = "action" + const val Vote = "vote" + const val Index = "index" +} + +/** + * The open poll as one of its players sees it. + * + * @param options the options as story markup. + * @param myVote what this player voted for, or -1. + * @param votes how many players have voted, of [voters]. + * @param decided the chosen option once the poll is over, or -1. + */ +@Serializable +data class ChoiceView( + val options: List = emptyList(), + val myVote: Int = -1, + val votes: Int = 0, + val voters: Int = 0, + val decided: Int = -1, +) + +/** Shows a poll in its own HUD window beside the crosshair, the default of [ChoiceEvent]. */ +object ChoiceOverlay : PollProvider { + val ID: ResourceLocation = "hollowengine-narrative:choice".rl + + private val sessions = HashMap>() + + override fun show(poll: Poll) { + sessions[poll.id] = poll.players.map { player -> + player.showOverlay(ID) { + put(ChoiceUiKeys.Choice, view(poll, player)) + onEvent { payload -> + if (payload.getString(ChoiceUiKeys.Action) == ChoiceUiKeys.Vote) { + poll.vote(player, payload.getInt(ChoiceUiKeys.Index)) + } + } + } + } + } + + override fun update(poll: Poll) { + sessions[poll.id]?.forEach { it.put(ChoiceUiKeys.Choice, view(poll, it.player)) } + } + + /** Marks the chosen option and lets the window play itself out. */ + override fun close(poll: Poll, chosen: Int) { + sessions.remove(poll.id)?.forEach { session -> + session.put(ChoiceUiKeys.Choice, view(poll, session.player).copy(decided = chosen)) + session.close() + } + } + + private fun view(poll: Poll, player: ServerPlayer) = ChoiceView( + options = poll.options.map { it.text }, + myVote = poll.votes[player] ?: -1, + votes = poll.votes.size, + voters = poll.players.size, + ) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/Choose.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/Choose.kt new file mode 100644 index 000000000..07fc12288 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/Choose.kt @@ -0,0 +1,64 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.poll + +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.ClickEvent +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.addons.narrative.speech.ChatText +import ru.hollowhorizon.hollowengine.addons.narrative.speech.audience +import ru.hollowhorizon.hollowengine.addons.narrative.story.Action +import ru.hollowhorizon.hollowengine.addons.narrative.story.ActionAlias +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryException +import ru.hollowhorizon.hollowengine.addons.narrative.story.action +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory + +/** + * Asks [to], or the audience of the scene it runs in, to choose one of [options] and returns the index of the chosen + * one; -1 when they chose an option a listener of [ChoiceEvent] added. In a story the choice is kept: a restart + * does not ask again. + * + * ``` + * when (choose(listOf("Help", "Leave"))) { + * 0 -> help() + * else -> leave() + * } + * ``` + */ +@EditorCategory("hollowengine.gui.ide.story.category.dialogue") +@Action("choose") +suspend fun choose(options: List, to: List? = null): Int = action(options, to) { + perform { + val players = to ?: audience() ?: throw StoryException("choose() outside a scene needs to = players") + val asked = options.mapIndexed { index, text -> PollOption(index.toString(), text) } + asked.indexOf(Polls.ask(players, asked)) + } +} + +/** + * [choose] among the [options] whose flag is set. Gives the index in [options], so that an option hidden this time + * does not shift the others; -1 when none is offered. + */ +@ActionAlias +suspend fun chooseIf(options: List>, to: List? = null): Int { + val offered = options.indices.filter { options[it].second } + if (offered.isEmpty()) return -1 + return offered.getOrElse(choose(offered.map { options[it].first }, to)) { -1 } +} + +/** Shows a poll as numbered options in chat; a click votes, and the chosen one is repeated once it is decided. */ +object ChatPollProvider : PollProvider { + override fun show(poll: Poll) { + poll.options.forEachIndexed { index, option -> + val line = Component.literal(" ${index + 1}. ").withStyle(ChatFormatting.GRAY) + .append(ChatText.of(option.text).withStyle(ChatFormatting.YELLOW)) + .withStyle { it.withClickEvent(ClickEvent(ClickEvent.Action.RUN_COMMAND, "${ChatText.COMMAND} choose $index")) } + poll.players.forEach { it.sendSystemMessage(line) } + } + } + + override fun close(poll: Poll, chosen: Int) { + val option = poll.options.getOrNull(chosen) ?: return + val line = Component.literal("Β» ").append(ChatText.of(option.text)).withStyle(ChatFormatting.GRAY, ChatFormatting.ITALIC) + poll.players.forEach { it.sendSystemMessage(line) } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/Poll.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/Poll.kt new file mode 100644 index 000000000..0894338d1 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/poll/Poll.kt @@ -0,0 +1,111 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.poll + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import kotlin.time.Duration + +/** One option of a poll. [key] tells it apart from the others and [text] is markup. */ +class PollOption(val key: String, val text: String) + +/** A vote of [players] between [options], shown by its [PollProvider]. */ +class Poll internal constructor( + val id: Int, + val players: List, + val options: List, + internal val provider: PollProvider, +) { + private val cast = LinkedHashMap() + private val decided = CompletableDeferred() + + /** What each player voted for so far. */ + val votes: Map get() = cast + + /** + * [player] votes for the option at [index]; false when they take no part, there is no such option or the poll is + * decided. It is decided once every player still online has voted. + */ + fun vote(player: ServerPlayer, index: Int): Boolean { + if (player !in players || index !in options.indices || decided.isCompleted) return false + cast[player] = index + provider.update(this) + if (cast.keys.containsAll(players.filter { !it.hasDisconnected() })) decided.complete(winner()) + return true + } + + /** Decides the poll for everyone, ignoring the votes: a script answers for the players, a test drives a scene. */ + fun decide(index: Int) { + decided.complete(index.coerceIn(options.indices)) + } + + /** Waits for the decision, or for [timeout] and then counts the votes cast by then. */ + internal suspend fun await(timeout: Duration?): Int = + if (timeout == null) decided.await() else withTimeoutOrNull(timeout) { decided.await() } ?: winner() + + /** The option with the most votes, ties broken at random and the first one when nobody voted. */ + private fun winner(): Int { + if (cast.isEmpty()) return 0 + val tally = IntArray(options.size) + cast.values.forEach { tally[it]++ } + val best = tally.max() + return tally.indices.filter { tally[it] == best }.random() + } +} + +/** Shows a poll to its players: the options, the votes as they come and how it ended. */ +interface PollProvider { + fun show(poll: Poll) + + /** A player voted; called for every vote, not just the deciding one. */ + fun update(poll: Poll) {} + + /** The poll ended with the option at [chosen], or -1 when it was given up. */ + fun close(poll: Poll, chosen: Int) {} +} + +/** + * Players are about to choose between [options]. A listener may add, remove or change options, or take the showing + * over with [sendPoll]: the poll is then shown by that provider instead of [ChoiceOverlay]; [ChatPollProvider] shows it + * in chat. + */ +class ChoiceEvent(val players: List, val options: MutableList) : Event { + var provider: PollProvider = ChoiceOverlay + private set + + fun sendPoll(provider: PollProvider) { + this.provider = provider + } + + companion object : EventHandler() +} + +/** The open polls, which `/hollowengine dialogue choose` votes in. */ +object Polls { + private var nextId = 0 + private val open = ArrayList() + + /** + * Asks [players] to choose one of [options], see [ChoiceEvent], and returns the chosen option; null when no option + * is left to choose. With [timeout] the votes cast by then decide. + */ + suspend fun ask(players: List, options: List, timeout: Duration? = null): PollOption? { + val event = ChoiceEvent.post(ChoiceEvent(players, options.toMutableList())) + if (event.options.isEmpty()) return null + val poll = Poll(nextId++, players, event.options.toList(), event.provider) + open += poll + var chosen = -1 + try { + poll.provider.show(poll) + chosen = poll.await(timeout) + } finally { + open -= poll + poll.provider.close(poll, chosen) + } + return poll.options[chosen] + } + + /** The poll [player] votes in: the latest open one they take part in. */ + fun of(player: ServerPlayer): Poll? = open.lastOrNull { player in it.players } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCharacter.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/DialogueCharacter.kt similarity index 92% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCharacter.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/DialogueCharacter.kt index 04ea206f2..ba91a4269 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCharacter.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/DialogueCharacter.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.scene import net.minecraft.world.entity.LivingEntity diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/DialogueCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/DialogueCommands.kt new file mode 100644 index 000000000..568e9d0ad --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/DialogueCommands.kt @@ -0,0 +1,41 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.scene + +import com.mojang.brigadier.arguments.IntegerArgumentType +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.tree.CommandNode +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import ru.hollowhorizon.hollowengine.addons.narrative.poll.Polls +import ru.hollowhorizon.hollowengine.addons.narrative.speech.Reading +import ru.hollowhorizon.hollowengine.common.utils.mcTranslate + +/** + * `/hollowengine dialogue advance` and `/hollowengine dialogue choose `: how a player moves past a line and votes + * from chat, and what the buttons of a line or a poll can run. + */ +internal object DialogueCommands { + fun register(root: CommandNode) { + val command = Commands.literal("dialogue") + .then(Commands.literal("advance").executes(::advance)) + .then( + Commands.literal("choose").then( + Commands.argument("option", IntegerArgumentType.integer(0)).executes(::choose), + ), + ) + root.addChild(command.build()) + } + + private fun advance(context: CommandContext): Int { + if (Reading.advance(context.source.playerOrException)) return 1 + context.source.sendFailure("hollowengine.commands.dialogue_not_in_dialogue".mcTranslate) + return 0 + } + + private fun choose(context: CommandContext): Int { + val player = context.source.playerOrException + val option = IntegerArgumentType.getInteger(context, "option") + if (Polls.of(player)?.vote(player, option) == true) return 1 + context.source.sendFailure("hollowengine.commands.dialogue_no_such_option".mcTranslate(option)) + return 0 + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/MediaActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/MediaActions.kt new file mode 100644 index 000000000..c77fb0afc --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/MediaActions.kt @@ -0,0 +1,117 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.scene.commands + +import kotlinx.coroutines.delay +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorAsset +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.playSound +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.playVfx +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.stopSound +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.updateSound +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.* +import ru.hollowhorizon.hollowengine.common.vfx.VfxFormat +import ru.hollowhorizon.hollowengine.common.vfx.VfxHandle +import kotlin.time.Duration.Companion.milliseconds + +private const val SOUND = "hollowengine.gui.ide.story.category.effects.sound" +private const val VFX = "hollowengine.gui.ide.story.category.effects.vfx" +private const val MATERIALS = "hollowengine.gui.ide.story.category.effects.materials" +private const val SCREEN = "hollowengine.gui.ide.story.category.effects.screen" +private const val ANIMATION = "hollowengine.gui.ide.story.category.animation" +private const val TIME = "hollowengine.gui.ide.story.category.time" + +private const val TICK_MILLIS = 50L + +/** The effects a story started by name, for a node to stop them later: a handle cannot be saved with the story. */ +private val namedVfx = HashMap() + +@EditorCategory(SOUND) +@SimpleAction("play-sound-named") +fun ServerLevel.playNamedSound(id: String, @EditorAsset("ogg", "wav", "mp3") sound: String, pos: Vec3? = null, volume: Float = 1f, pitch: Float = 1f) { + playSound(sound) { + this.id = id + this.volume = volume + this.pitch = pitch + this.position = pos + } +} + +@EditorCategory(SOUND) +@SimpleAction("stop-sound") +fun ServerLevel.stopNamedSound(id: String, fadeOut: Float = 0f) = stopSound(id, fadeOut) + +@EditorCategory(SOUND) +@SimpleAction("change-sound") +fun ServerLevel.changeNamedSound(id: String, volume: Float = 1f, pitch: Float = 1f) { + updateSound(id, volume, pitch) +} + +@EditorCategory(VFX) +@SimpleAction("play-vfx") +fun ServerLevel.playNamedVfx(id: String, @EditorAsset(VfxFormat.EXTENSION) effect: String, pos: Vec3) { + namedVfx.put(id, playVfx(pos, effect))?.stop() +} + +@EditorCategory(VFX) +@SimpleAction("play-vfx-on") +fun Entity.playNamedVfx(id: String, @EditorAsset(VfxFormat.EXTENSION) effect: String) { + namedVfx.put(id, playVfx(effect))?.stop() +} + +@EditorCategory(VFX) +@SimpleAction("move-vfx") +fun moveNamedVfx(id: String, pos: Vec3) { + namedVfx[id]?.moveTo(pos) +} + +@EditorCategory(VFX) +@SimpleAction("stop-vfx") +fun stopNamedVfx(id: String, immediate: Boolean = false) { + namedVfx.remove(id)?.stop(immediate) +} + +@EditorCategory(SCREEN) +@SimpleAction("play-video") +fun ServerPlayer.playVideo(@EditorAsset(".mp4", ".webm", ".mkv", ".mov", ".avi") source: String) = PlayVideoPacket(source).send(this) + +@EditorCategory(MATERIALS) +@SimpleAction("set-material") +fun Entity.setMaterial(material: String, @EditorAsset("png") texture: String) { + materials[material] = texture(texture) +} + +@EditorCategory(MATERIALS) +@SimpleAction("reset-material") +fun Entity.resetMaterial(material: String) = materials.reset(material) + +@EditorCategory(MATERIALS) +@SimpleAction("reset-materials") +fun Entity.resetMaterials() = materials.clear() + +@EditorCategory(ANIMATION) +@SimpleAction("play-montage") +suspend fun Entity.playMontageToEnd(montage: String, fadeIn: Double = 0.2, fadeOut: Double = 0.2, speed: Double = 1.0) { + playMontage(montage, fadeIn, fadeOut, speed) +} + +@EditorCategory(ANIMATION) +@SimpleAction("start-montage") +fun Entity.beginMontage(montage: String, fadeIn: Double = 0.2, fadeOut: Double = 0.2, speed: Double = 1.0) { + startMontage(montage, fadeIn, fadeOut, speed) +} + +@EditorCategory(ANIMATION) +@SimpleAction("stop-montage") +fun Entity.endMontage(montage: String, fadeOut: Double = 0.2) = stopMontage(montage, fadeOut) + +@EditorCategory(ANIMATION) +@SimpleAction("stop-all-animations") +fun Entity.endAllAnimations() = stopAllAnimations() + +@EditorCategory(TIME) +@SimpleAction("next-tick") +suspend fun nextTick() = delay(TICK_MILLIS.milliseconds) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/SceneActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/SceneActions.kt new file mode 100644 index 000000000..9717e2985 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/SceneActions.kt @@ -0,0 +1,78 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.scene.commands + +import kotlinx.coroutines.delay +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.addons.narrative.story.Action +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.addons.narrative.story.action +import ru.hollowhorizon.hollowengine.common.models.AnimationPlayMode +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.playSound +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.play +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.playAndWait +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.stopAnimation +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorAnimation +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorIcon +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorName +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorWidget + +/** `@wait 2s`. A restart in the middle waits only for what was left: the deadline is kept with the call. */ +@EditorCategory("hollowengine.gui.ide.story.category.time") +@EditorName("hollowengine.gui.ide.story.kind.wait") +@EditorIcon("hollowengine-narrative:textures/gui/icons/story/wait.svg") +@EditorWidget("hollowengine-narrative:wait") +@Action("wait") +suspend fun waitFor(time: Duration) = action(time) { + val deadline = capture { System.currentTimeMillis() + time.inWholeMilliseconds } + perform { + while (true) { + val remaining = deadline - System.currentTimeMillis() + if (remaining <= 0L) break + delay(minOf(remaining, MAX_SLEEP_MILLIS).milliseconds) + } + } +} + +/** `@play-sound player minecraft:block.note_block.bell 1.0`. */ +@EditorCategory("hollowengine.gui.ide.story.category.effects") +@SimpleAction("play-sound") +fun ServerPlayer.playSceneSound(name: String, volume: Float = 1f, pitch: Float = 1f) { + playSound(name, volume, pitch) +} + +/** `@play-once Π’ΠΈΡ‚Π°Π»ΠΈΠΊ hello`: plays the animation once and waits for it to end. */ +@EditorCategory("hollowengine.gui.ide.story.category.animation") +@SimpleAction("play-once") +suspend fun Entity.animateOnce(@EditorAnimation animation: String, fade: Duration = DEFAULT_FADE) { + playAndWait(animation, fade.seconds, fade.seconds) +} + +/** Plays the animation over and over until it is stopped; does not wait. */ +@EditorCategory("hollowengine.gui.ide.story.category.animation") +@SimpleAction("play-looped") +fun Entity.animateLooped(@EditorAnimation animation: String, fade: Duration = DEFAULT_FADE) = + play(animation, AnimationPlayMode.Loop, fade.seconds, fade.seconds) + +/** Plays the animation once and holds its last frame; does not wait. */ +@EditorCategory("hollowengine.gui.ide.story.category.animation") +@SimpleAction("play-clamped") +fun Entity.animateClamped(@EditorAnimation animation: String, fade: Duration = DEFAULT_FADE) = + play(animation, AnimationPlayMode.ClampForever, fade.seconds, fade.seconds) + +/** Plays the animation forward and back until it is stopped; does not wait. */ +@EditorCategory("hollowengine.gui.ide.story.category.animation") +@SimpleAction("play-ping-pong") +fun Entity.animatePingPong(@EditorAnimation animation: String, fade: Duration = DEFAULT_FADE) = + play(animation, AnimationPlayMode.PingPong, fade.seconds, fade.seconds) + +@EditorCategory("hollowengine.gui.ide.story.category.animation") +@SimpleAction("stop-animation") +fun Entity.stopAnimating(@EditorAnimation animation: String, fade: Duration = DEFAULT_FADE) = stopAnimation(animation, fade.seconds) + +private val Duration.seconds: Double get() = inWholeMilliseconds / 1000.0 + +private val DEFAULT_FADE = 330.milliseconds +private const val MAX_SLEEP_MILLIS = 1_000L diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/ScreenEffects.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/ScreenEffects.kt new file mode 100644 index 000000000..861e5025a --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/ScreenEffects.kt @@ -0,0 +1,200 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.scene.commands + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.playCutscene +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.stopCutscene +import ru.hollowhorizon.hollowengine.addons.narrative.speech.StoryAudience +import ru.hollowhorizon.hollowengine.addons.narrative.story.* +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.entity.LivingEntityDeathEvent +import ru.hollowhorizon.hollowengine.common.ui.hud.EngineHudLayers +import ru.hollowhorizon.hollowengine.common.ui.hud.VanillaHudLayers +import ru.hollowhorizon.hollowengine.common.ui.net.hiddenHudLayers +import ru.hollowhorizon.hollowengine.common.ui.net.hideHudLayers +import ru.hollowhorizon.hollowengine.common.ui.net.showAllHudLayers +import java.util.UUID +import java.util.WeakHashMap +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorWidget + +/** `@fade-in player 600ms #000000`: covers the screen of [this] with [color]. Undone, the screen is clear again. */ +@EditorCategory("hollowengine.gui.ide.story.category.effects.screen") +@Action("fade-in") +suspend fun ServerPlayer.fadeIn(time: Duration = DEFAULT_FADE, @EditorWidget("color") color: String = BLACK) = + action(this, time, color) { + undo { StoryClearFadePacket.send(this@fadeIn) } + fastForward { StoryFadePacket(1f, 0L, parseColor(color)).send(this@fadeIn) } + perform { + ScreenEffects.borrow(this@fadeIn)?.fade = true + StoryFadePacket(1f, time.inWholeMilliseconds, parseColor(color)).send(this@fadeIn) + delay(time) + } + } + +/** Uncovers the screen [fadeIn] covered. Undone, it is covered with [color] again. */ +@EditorCategory("hollowengine.gui.ide.story.category.effects.screen") +@Action("fade-out") +suspend fun ServerPlayer.fadeOut(time: Duration = DEFAULT_FADE, color: String = BLACK) = + action(this, time, color) { + undo { StoryFadePacket(1f, 0L, parseColor(color)).send(this@fadeOut) } + fastForward { StoryClearFadePacket.send(this@fadeOut) } + perform { + StoryFadePacket(0f, time.inWholeMilliseconds, parseColor(color)).send(this@fadeOut) + delay(time) + } + } + +/** + * `@hide-hud player` hides the whole HUD, `except=[chat]` keeps what is listed and `only=[crosshair, hotbar]` hides + * just those; vanilla layers go by their bare names. Undone, the HUD is as it was before. + */ +@EditorCategory("hollowengine.gui.ide.story.category.effects.screen") +@Action("hide-hud") +suspend fun ServerPlayer.hideHud( + @Suggestions(HudLayerNames::class) only: List? = null, + @Suggestions(HudLayerNames::class) except: List? = null, +) = action(this, only, except) { + val before = capture { hiddenHudLayers().map(ResourceLocation::toString) } + undo { restoreHud(before) } + perform { + val layers = (only?.let(::layersOf) ?: ALL_LAYERS) - except?.let(::layersOf).orEmpty() + ScreenEffects.borrow(this@hideHud)?.hudBefore?.putIfAbsent(uuid, hiddenHudLayers()) + hideHudLayers(*layers.toTypedArray()) + } +} + +/** Shows the HUD as it was before the scene hid it, or all of it. Undone, the HUD is as it was before. */ +@EditorCategory("hollowengine.gui.ide.story.category.effects.screen") +@Action("show-hud") +suspend fun ServerPlayer.showHud() = action(this) { + val before = capture { hiddenHudLayers().map(ResourceLocation::toString) } + undo { restoreHud(before) } + perform { ScreenEffects.giveHudBack(this@showHud, ScreenEffects.borrowed(this@showHud)?.hudBefore?.remove(uuid)) } +} + +/** + * `@camera player [25, 70, -171] [-158, 2] time=1500 fov=60`: moves the camera of [this] to [location] facing + * [rotation], `[yaw, pitch]` or `[yaw, pitch, roll]` as F3 shows them. Undone, the player has their camera back. + */ +@EditorCategory("hollowengine.gui.ide.story.category.camera") +@Action("camera") +suspend fun ServerPlayer.moveCamera( + location: Vec3, + rotation: List, + time: Duration = DEFAULT_CAMERA, + fov: Float = DEFAULT_FOV, +) = action(this, location, rotation, time, fov) { + require(rotation.size in 2..3) { "camera rotation must be [yaw, pitch] or [yaw, pitch, roll]" } + fun pose(duration: Duration) = StoryCameraPacket( + location.x.toFloat(), location.y.toFloat(), location.z.toFloat(), + pitch = rotation[1], yaw = rotation[0], roll = rotation.getOrElse(2) { 0f }, + fov = fov, + durationMillis = duration.inWholeMilliseconds, + ) + undo { StoryReleaseCameraPacket(0L).send(this@moveCamera) } + fastForward { pose(Duration.ZERO).send(this@moveCamera) } + perform { + ScreenEffects.borrow(this@moveCamera)?.camera = true + pose(time).send(this@moveCamera) + delay(time) + } +} + +/** Gives the player their camera back over [time]. */ +@EditorCategory("hollowengine.gui.ide.story.category.camera") +@SimpleAction("release-camera") +suspend fun ServerPlayer.releaseCamera(time: Duration = DEFAULT_CAMERA) { + StoryReleaseCameraPacket(time.inWholeMilliseconds).send(this) + delay(time) +} + +/** Plays the cutscene [name] to [this]; waits [time] when given. */ +@EditorCategory("hollowengine.gui.ide.story.category.camera") +@SimpleAction("cutscene") +suspend fun ServerPlayer.showCutscene(name: String, time: Duration? = null) { + ScreenEffects.borrow(this)?.cutscene = true + playCutscene(name) + time?.let { delay(it) } +} + +/** + * The screens scenes borrow. A scene that faded the screen out, hid the HUD or took the camera gives them back when it + * ends for any reason, a crash and a server stop included, and when the player dies: nobody may be left staring at a + * black screen with no HUD and a camera that is not theirs. + */ +internal object ScreenEffects { + class Borrowed { + var fade = false + var camera = false + var cutscene = false + val players = LinkedHashSet() + + /** What each player had hidden before the scene touched the HUD. */ + val hudBefore = HashMap>() + } + + private val borrowed = WeakHashMap() + + /** What the scene the current command runs in borrowed, with [player] noted; null outside a scene. */ + suspend fun borrow(player: ServerPlayer): Borrowed? { + val audience = currentCoroutineContext()[StoryAudience] ?: return null + return borrowed.getOrPut(audience) { Borrowed() }.also { it.players += player } + } + + suspend fun borrowed(player: ServerPlayer): Borrowed? = + currentCoroutineContext()[StoryAudience]?.let(borrowed::get)?.takeIf { player in it.players } + + /** Gives back what the scene of [audience] borrowed, as it ends. */ + fun giveBack(audience: StoryAudience) { + val state = borrowed.remove(audience) ?: return + state.players.forEach { giveBack(it, state) } + } + + @SubscribeEvent + fun onPlayerDeath(event: LivingEntityDeathEvent) { + val player = event.entity as? ServerPlayer ?: return + borrowed.values.filter { player in it.players }.forEach { giveBack(player, it) } + } + + private fun giveBack(player: ServerPlayer, state: Borrowed) { + if (state.fade) StoryClearFadePacket.send(player) + if (state.camera || state.cutscene) { + StoryReleaseCameraPacket(0L).send(player) + player.stopCutscene() + } + if (state.hudBefore.containsKey(player.uuid)) giveHudBack(player, state.hudBefore.remove(player.uuid)) + } + + fun giveHudBack(player: ServerPlayer, before: Set?) { + if (before.isNullOrEmpty()) player.showAllHudLayers() else player.hideHudLayers(*before.toTypedArray()) + } +} + +private fun ServerPlayer.restoreHud(hidden: List) = + ScreenEffects.giveHudBack(this, hidden.mapNotNull(ResourceLocation::tryParse).toSet()) + +/** The HUD layers by the names [hideHud] takes: bare for vanilla ones. */ +object HudLayerNames : SuggestionSource { + override val values: List + get() = ALL_LAYERS.map { if (it.namespace == "minecraft") it.path else it.toString() }.sorted() +} + +/** Accepts bare vanilla names (`chat`) and full ids alike. */ +private fun layersOf(names: List): Set = + names.map { EngineHudLayers.parse(it.trim()) }.toSet() + +/** `#RRGGBB`, or black when it is not one. */ +private fun parseColor(color: String): Int = color.removePrefix("#").toIntOrNull(16) ?: 0x000000 + +private val ALL_LAYERS: Set get() = (VanillaHudLayers.all + EngineHudLayers.all).toSet() + +private val DEFAULT_FADE = 500.milliseconds +private val DEFAULT_CAMERA = 1_000.milliseconds +private const val DEFAULT_FOV = 70f +private const val BLACK = "#000000" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectPackets.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/ScreenPackets.kt similarity index 64% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectPackets.kt rename to addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/ScreenPackets.kt index cfb590795..abf454b01 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectPackets.kt +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/scene/commands/ScreenPackets.kt @@ -1,11 +1,12 @@ -package ru.hollowhorizon.hollowengine.common.dialogue +package ru.hollowhorizon.hollowengine.addons.narrative.scene.commands import kotlinx.serialization.Serializable import net.minecraft.world.entity.player.Player -import ru.hollowhorizon.hollowengine.client.dialogue.StoryCameraSystem -import ru.hollowhorizon.hollowengine.client.dialogue.StoryFadeOverlay -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CameraPose -import ru.hollowhorizon.hollowengine.common.network.HollowPacket +import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryCameraSystem +import ru.hollowhorizon.hollowengine.addons.narrative.client.StoryFadeOverlay +import ru.hollowhorizon.hollowengine.api.VideoApi +import ru.hollowhorizon.hollowengine.client.camera.CameraPose +import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f @@ -15,13 +16,21 @@ class StoryFadePacket( private val alpha: Float, private val durationMillis: Long, private val color: Int, -) : HollowPacket { +) : HollowAddonPacket { override fun handle(player: Player) = StoryFadeOverlay.fadeTo(alpha, durationMillis, color) } @HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) @Serializable -object StoryClearFadePacket : HollowPacket { +class PlayVideoPacket(private val source: String) : HollowAddonPacket { + override fun handle(player: Player) { + VideoApi.find()?.play(source) + } +} + +@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) +@Serializable +object StoryClearFadePacket : HollowAddonPacket { override fun handle(player: Player) = StoryFadeOverlay.clear() } @@ -36,7 +45,7 @@ class StoryCameraPacket( private val roll: Float, private val fov: Float, private val durationMillis: Long, -) : HollowPacket { +) : HollowAddonPacket { override fun handle(player: Player) = StoryCameraSystem.moveTo( CameraPose(Vec3f(x, y, z), Vec3f(pitch, yaw, roll), fov), durationMillis, @@ -45,6 +54,6 @@ class StoryCameraPacket( @HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) @Serializable -class StoryReleaseCameraPacket(private val durationMillis: Long) : HollowPacket { +class StoryReleaseCameraPacket(private val durationMillis: Long) : HollowAddonPacket { override fun handle(player: Player) = StoryCameraSystem.release(durationMillis) } diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/ChatLineProvider.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/ChatLineProvider.kt new file mode 100644 index 000000000..e3f77a476 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/ChatLineProvider.kt @@ -0,0 +1,29 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.ClickEvent +import net.minecraft.network.chat.Component +import kotlin.time.Duration.Companion.milliseconds + +/** + * Shows a line in chat under its speaker's name and moves on once it is read, or when it is clicked. + */ +object ChatLineProvider : LineProvider { + /** The shortest time a line stays. */ + var minReadMillis = 1200L + + /** How long a character takes to read. */ + var readMillisPerChar = 45L + + override suspend fun show(line: Line) { + val length = line.text.visibleLength + if (line.text.plainText.isBlank()) return + val message = Component.empty() + line.speaker?.let { message.append(Component.literal("${it.name}: ").withStyle(ChatFormatting.GOLD)) } + val text = ChatText.of(line.text) + message.append(if (line.speaker == null) text.withStyle(ChatFormatting.ITALIC) else text) + message.withStyle { it.withClickEvent(ClickEvent(ClickEvent.Action.RUN_COMMAND, "${ChatText.COMMAND} advance")) } + line.audience.forEach { it.sendSystemMessage(message) } + Reading.awaitAdvance(line.audience, maxOf(minReadMillis, length * readMillisPerChar).milliseconds) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/ChatText.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/ChatText.kt new file mode 100644 index 000000000..a236530ad --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/ChatText.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.MutableComponent +import net.minecraft.network.chat.Style +import net.minecraft.network.chat.TextColor +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextDocument +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextStyle + +/** Story markup as chat can show it: animations are dropped and a gradient keeps its first color. */ +internal object ChatText { + /** The command chat buttons of lines and choices run. */ + const val COMMAND = "/hollowengine dialogue" + + fun of(markup: String): MutableComponent = of(FormattedTextParser.parse(StoryText.localized(markup))) + + fun of(document: FormattedTextDocument): MutableComponent { + val result = Component.empty() + document.spans.forEach { span -> result.append(Component.literal(span.text).withStyle(styleOf(span.styles))) } + return result + } + + private fun styleOf(styles: List): Style = styles.fold(Style.EMPTY) { style, it -> + when (it) { + FormattedTextStyle.Bold -> style.withBold(true) + FormattedTextStyle.Italic -> style.withItalic(true) + FormattedTextStyle.Underline -> style.withUnderlined(true) + FormattedTextStyle.Strikethrough -> style.withStrikethrough(true) + is FormattedTextStyle.Color -> style.withColor(it.rgb) + is FormattedTextStyle.Gradient -> style.withColor(it.from.rgb) + is FormattedTextStyle.Animation -> style + } + } + + private val FormattedTextStyle.Color.rgb: TextColor get() = TextColor.fromRgb(red shl 16 or (green shl 8) or blue) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/Reading.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/Reading.kt new file mode 100644 index 000000000..0b80d63af --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/Reading.kt @@ -0,0 +1,31 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import net.minecraft.server.level.ServerPlayer +import kotlin.time.Duration + +/** The players reading a line, and the click on it that moves them on: `/hollowengine dialogue advance`. */ +object Reading { + private val waiting = HashMap>() + + /** + * Waits until one of [players] moves on, or [timeout] passes when it is not null; returns whether someone moved + * on. A later wait of the same player takes the click from an earlier one. + */ + suspend fun awaitAdvance(players: List, timeout: Duration? = null): Boolean { + val signal = CompletableDeferred() + players.forEach { waiting[it] = signal } + try { + return if (timeout == null) { + signal.await() + true + } else withTimeoutOrNull(timeout) { signal.await() } != null + } finally { + players.forEach { player -> if (waiting[player] === signal) waiting.remove(player) } + } + } + + /** Moves [player] on; false when they read nothing right now. */ + fun advance(player: ServerPlayer): Boolean = waiting.remove(player)?.complete(Unit) == true +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/Speech.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/Speech.kt new file mode 100644 index 000000000..a8d68c959 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/Speech.kt @@ -0,0 +1,85 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import kotlinx.coroutines.currentCoroutineContext +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.LivingEntity +import ru.hollowhorizon.hollowengine.addons.narrative.scene.DialogueCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.scene.EntityCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.story.SimpleAction +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryException +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextDocument +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** How far a line said outside a scene carries: the players this close to its speaker hear it. */ +private const val HEARING_DISTANCE = 24.0 + +/** + * A line [speaker] says to [audience], narration when [speaker] is null. [markup] is the text as written, with the + * tags of [FormattedTextParser]. + */ +class Line(val speaker: DialogueCharacter?, val markup: String, val audience: List) { + val text: FormattedTextDocument by lazy(LazyThreadSafetyMode.NONE) { FormattedTextParser.parse(StoryText.localized(markup)) } +} + +/** Shows a line and returns once its audience has read it. */ +fun interface LineProvider { + suspend fun show(line: Line) +} + +/** + * A line is about to be shown. A listener may change [line], or take the showing over with [sendLine]: the line is + * then shown by that provider instead of [SpeechOverlay]; [ChatLineProvider] shows it in chat. + */ +class SayEvent(var line: Line) : Event { + var provider: LineProvider = SpeechOverlay + private set + + fun sendLine(provider: LineProvider) { + this.provider = provider + } + + companion object : EventHandler() +} + +/** Who hears the lines and votes in the choices of what runs inside it, as a scene sets for its participants. */ +class StoryAudience(val players: List) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} + +/** + * Says [text] and returns once it is read. It is heard by [to], or else by the audience of the scene it runs in, or + * else by the players near [speaker]. + */ +@EditorCategory("hollowengine.gui.ide.story.category.dialogue") +@SimpleAction("say") +suspend fun say(speaker: DialogueCharacter?, text: String, to: List? = null) { + val audience = (to ?: audience() ?: speaker.listeners()).filter { !it.hasDisconnected() } + val event = SayEvent.post(SayEvent(Line(speaker, text, audience))) + event.provider.show(event.line) +} + +/** [text] said by [speaker], see [say]. */ +suspend fun say(speaker: LivingEntity, text: String, to: List? = null) = + say(DialogueCharacter.of(speaker), text, to) + +/** [text] said under the name [speaker], narration when it is null, see [say]. */ +suspend fun say(speaker: String?, text: String, to: List? = null) = + say(speaker?.let(DialogueCharacter::of), text, to) + +/** The name of the first player of the scene the caller runs in. */ +suspend fun playerName(): String = audience()?.firstOrNull()?.scoreboardName.orEmpty() + +/** The players of the scene the caller runs in, or null outside one. */ +internal suspend fun audience(): List? = currentCoroutineContext()[StoryAudience]?.players + +private fun DialogueCharacter?.listeners(): List { + val entity = (this as? EntityCharacter)?.entity + ?: throw StoryException("A line of ${this?.name ?: "the narrator"} outside a scene needs to = players") + return entity.level().players().filterIsInstance() + .filter { it.distanceToSqr(entity) <= HEARING_DISTANCE * HEARING_DISTANCE } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/SpeechOverlay.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/SpeechOverlay.kt new file mode 100644 index 000000000..e4bd2eebc --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/SpeechOverlay.kt @@ -0,0 +1,104 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import kotlinx.serialization.Serializable +import net.minecraft.Util +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.addons.narrative.poll.Polls +import ru.hollowhorizon.hollowengine.common.data.dataKey +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.tick.TickEvent +import ru.hollowhorizon.hollowengine.common.ui.net.UiSession +import ru.hollowhorizon.hollowengine.common.ui.net.showOverlay +import ru.hollowhorizon.hollowengine.common.utils.rl +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +/** + * What [SpeechOverlay] writes into its UI session. An overlay a `.ui.kts` declares with [SpeechOverlay.ID] replaces + * the built-in one and reads the same keys. + */ +object SpeechUiKeys { + val Line = dataKey("line") { SpeechLineView() } + + /** `send { putString(Action, Advance) }` moves past the line, as `/hollowengine dialogue advance` does. */ + const val Action = "action" + const val Advance = "advance" +} + +/** + * The line on screen. The text arrives whole and the client types it out, so the animation stays off the network. + * + * @param id bumped for every line; a change restarts the typing. + * @param speaker the name shown above the text, empty for narration. + * @param text story markup, translated where it is shown. + * @param charMillis milliseconds the typing spends on a character, 0 to show the line at once. + * @param readMillis how long the line stays before the story moves on. + */ +@Serializable +data class SpeechLineView( + val id: Int = 0, + val speaker: String = "", + val text: String = "", + val charMillis: Int = 0, + val readMillis: Long = 0, +) + +/** + * Shows lines in a HUD overlay above the hotbar, the default of [SayEvent]. A line moves on once it is read; the + * overlay stays a while after the last one and fades out unless another line comes or a choice is open. + */ +object SpeechOverlay : LineProvider { + val ID: ResourceLocation = "hollowengine-narrative:speech".rl + + /** How long a line stays when its length is unknown here: the server lacks the translation its players read. */ + private const val UNTRANSLATED_READ_MILLIS = 8000L + + private class Shown(val session: UiSession, var line: Int) { + var hideAt = Long.MAX_VALUE + } + + private val shown = HashMap() + private var nextId = 0 + + override suspend fun show(line: Line) { + if (line.text.plainText.isBlank()) return + val id = ++nextId + val read = maxOf(if (StoryText.hasKeys(line.text.plainText)) UNTRANSLATED_READ_MILLIS else 1500L, line.text.visibleLength * 50L) + val view = SpeechLineView(id, line.speaker?.name.orEmpty(), line.markup, 18, read) + line.audience.forEach { player -> + val overlay = shown[player.uuid] ?: open(player) + overlay.session.put(SpeechUiKeys.Line, view) + overlay.line = id + overlay.hideAt = Long.MAX_VALUE + } + try { + Reading.awaitAdvance(line.audience, read.milliseconds) + } finally { + val hideAt = Util.getMillis() + 2500L + line.audience.forEach { player -> shown[player.uuid]?.takeIf { it.line == id }?.hideAt = hideAt } + } + } + + private fun open(player: ServerPlayer): Shown { + lateinit var overlay: Shown + val session = player.showOverlay(ID) { + onEvent { payload -> if (payload.getString(SpeechUiKeys.Action) == SpeechUiKeys.Advance) Reading.advance(player) } + onClose { shown.remove(player.uuid, overlay) } + } + overlay = Shown(session, 0) + shown[player.uuid] = overlay + return overlay + } + + @SubscribeEvent + fun onServerTick(event: TickEvent.Server) { + if (shown.isEmpty()) return + val now = Util.getMillis() + val expired = shown.values.filter { now >= it.hideAt && Polls.of(it.session.player) == null } + expired.forEach { overlay -> + shown.remove(overlay.session.player.uuid, overlay) + overlay.session.close() + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/StoryText.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/StoryText.kt new file mode 100644 index 000000000..4f5464305 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/speech/StoryText.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import net.minecraft.locale.Language + +/** + * Translations in story text: a whole line can be a translation key, and `{modid.some.key}` inside a line puts one in. + * Keys are resolved where the text is shown, so each player reads their own language. + */ +object StoryText { + private const val KEY = """[\w-]+(?:\.[\w-]+)+""" + private val WHOLE_KEY = Regex(KEY) + private val INSERTED_KEY = Regex("""\{($KEY)}""") + + /** + * [markup] with its keys translated; a key [translation] does not know stays as written. + * + * @param translation the text of a key, null when there is none. + */ + fun localized(markup: String, translation: (String) -> String? = ::known): String { + translation(markup.trim())?.let { return it } + return INSERTED_KEY.replace(markup) { translation(it.groupValues[1]) ?: it.value } + } + + /** Whether [text] still holds a key: a server that lacks the language of a mod knows nothing of how long it reads. */ + fun hasKeys(text: String): Boolean = WHOLE_KEY.matches(text.trim()) || INSERTED_KEY.containsMatchIn(text) + + private fun known(key: String): String? = Language.getInstance().takeIf { it.has(key) }?.getOrDefault(key) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/ActionCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/ActionCommands.kt new file mode 100644 index 000000000..0a7d059a7 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/ActionCommands.kt @@ -0,0 +1,172 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.core.BlockPos +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryFunctionSignature +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryParam +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryParamGroup +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryParamMeaning +import ru.hollowhorizon.hollowengine.addons.narrative.scene.DialogueCharacter +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorAnimation +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorAsset +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorDescription +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorHidden +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorIcon +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorModel +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorName +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorNpcId +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorWidget +import java.lang.reflect.Modifier +import kotlin.reflect.KClass +import kotlin.reflect.KParameter +import kotlin.reflect.KType +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.primaryConstructor +import kotlin.time.Duration + +/** The actions as the commands of a story: `move-to` of whichever namespace has one, or `ns:move-to` of that one. */ +object ActionCommands { + private class Built(val actions: List, val commands: List) + + @Volatile + private var built = Built(emptyList(), emptyList()) + + /** Every action a story can call: one whose required parameters all take values a field can hold. */ + val commands: List + get() { + val actions = StoryActions.all + built.takeIf { it.actions === actions }?.let { return it.commands } + return Built(actions, actions.mapNotNull(StoryCommand::of)).also { built = it }.commands + } + + /** The commands [name] may mean: one, unless several namespaces declare it. */ + fun find(name: String): List = commands.filter { it.answersTo(name) } +} + +/** An action as a story calls it. */ +class StoryCommand private constructor(val action: StoryAction, val signature: StoryFunctionSignature) { + val id: String get() = action.id + + internal fun answersTo(written: String): Boolean = if (':' in written) id == written else id.substringAfter(':') == written + + internal companion object { + /** The command [action] is, or null when a story cannot call it: one of a script is not reachable by name. */ + fun of(action: StoryAction): StoryCommand? { + if (!Modifier.isStatic(action.method.modifiers)) return null + val direct = ArrayList() + val options = ArrayList() + for (parameter in action.function.parameters) { + when (parameter.kind) { + KParameter.Kind.VALUE, KParameter.Kind.EXTENSION_RECEIVER -> Unit + else -> return null + } + if (parameter.type.classifier == CheckpointScope::class) return null + if (!isValue(parameter.type)) { + val fields = optionsOf(parameter) + if (fields != null) options += fields else if (!parameter.isOptional) return null + continue + } + direct += param(parameter) + } + // A field of an options object named like a parameter of the action is left to the parameter. + val taken = direct.mapTo(HashSet()) { it.name } + val function = action.function + val signature = StoryFunctionSignature( + action.id, + "${action.method.declaringClass.packageName}.${function.name}", + direct + options.filter { taken.add(it.name) }, + title = function.findAnnotation()?.name, + description = function.findAnnotation()?.description, + icon = function.findAnnotation()?.icon, + category = function.findAnnotation()?.category, + categoryColor = function.findAnnotation()?.color?.ifEmpty { null }, + returns = function.returnType.takeIf { it.classifier is KClass<*> && it.classifier != Unit::class } + ?.toString()?.replace("!", ""), + hidden = function.hasAnnotation(), + ) + return StoryCommand(action, signature) + } + + private fun param(parameter: KParameter): StoryParam { + val info = parameter.findAnnotation() + val receiver = parameter.kind == KParameter.Kind.EXTENSION_RECEIVER + val implicit = parameter.type.classifier == ServerLevel::class && !parameter.isOptional + return StoryParam( + name = if (receiver) receiverName(parameter) else parameter.name.orEmpty(), + type = parameter.type, + optional = parameter.isOptional || implicit, + suggestions = parameter.findAnnotation()?.source?.objectInstance?.values + ?: suggestions(parameter.type), + title = info?.title?.ifEmpty { null }, + description = info?.description?.ifEmpty { null }, + receiver = receiver, + implicit = if (implicit) "storyLevel()" else null, + assets = parameter.findAnnotation()?.extensions?.toList().orEmpty(), + widget = parameter.findAnnotation()?.id, + meaning = meaningOf(parameter), + ) + } + + private fun meaningOf(parameter: KParameter): StoryParamMeaning? = when { + parameter.hasAnnotation() -> StoryParamMeaning.ANIMATION + parameter.hasAnnotation() -> StoryParamMeaning.MODEL + parameter.hasAnnotation() -> StoryParamMeaning.NPC_ID + else -> null + } + + private fun receiverName(parameter: KParameter): String { + val classifier = parameter.type.classifier as KClass<*> + return when { + classifier == ServerLevel::class -> "level" + classifier.isSubclassOf(Entity::class) -> "who" + else -> "this" + } + } + + /** + * An optional parameter whose class can be built from optional constructor parameters alone, such as + * `MoveOptions`: a story sets those of them it has values for, by name. + */ + private fun optionsOf(parameter: KParameter): List? { + if (!parameter.isOptional) return null + val type = parameter.type.classifier as? KClass<*> ?: return null + val constructor = type.primaryConstructor ?: return null + if (constructor.parameters.any { !it.isOptional }) return null + val group = StoryParamGroup(parameter.name ?: return null, type.qualifiedName ?: return null) + return constructor.parameters.filter { it.name != null && isValue(it.type) } + .map { param(it).copy(optional = true, group = group) }.ifEmpty { null } + } + + /** Whether a field can hold a value of [type]. */ + private fun isValue(type: KType): Boolean { + val classifier = type.classifier as? KClass<*> ?: return false + return when { + classifier in VALUES || classifier.java.isEnum -> true + classifier.isSubclassOf(Entity::class) || classifier.isSubclassOf(DialogueCharacter::class) -> true + classifier in COLLECTIONS -> type.arguments.singleOrNull()?.type?.let(::isValue) == true + else -> false + } + } + + /** The values the editor offers for [type]: the constants of an enum, of a list of them too. */ + private fun suggestions(type: KType): List { + val classifier = type.classifier as? KClass<*> ?: return emptyList() + if (classifier in COLLECTIONS) return type.arguments.singleOrNull()?.type?.let(::suggestions).orEmpty() + if (classifier == ServerLevel::class) return DIMENSIONS + return classifier.java.enumConstants?.map { (it as Enum<*>).name.lowercase().replace('_', '-') }.orEmpty() + } + + private val VALUES = setOf( + String::class, Boolean::class, Int::class, Long::class, Short::class, Byte::class, Float::class, Double::class, + Duration::class, ResourceLocation::class, ServerLevel::class, Vec3::class, BlockPos::class, + ) + private val COLLECTIONS = setOf(List::class, Collection::class, Iterable::class, Set::class) + private val DIMENSIONS = listOf("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end") + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt new file mode 100644 index 000000000..39c440f0c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/Actions.kt @@ -0,0 +1,256 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import net.minecraft.world.entity.Entity +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KClass + +/** + * A reversible action. Its body is `action { }`: within a story every call is written to the journal, which can undo + * it on a rewind, after a restart too. The journal keeps the receivers and parameters + * of the call, so they must be serializable; entities are kept as references. + * + * @param id the id the journal and editors know the action by, in the namespace of its script or addon; the + * function name when empty. Overloads need ids of their own. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Action(val id: String = "") + +/** + * A parameter of an [Action] the journal does not keep, such as options holding a lambda. Undoing or fast-forwarding + * the call gets its default value instead, so it must have one, and the blocks must not depend on it. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Unsaved + +/** + * A shortcut that performs exactly one [Action], such as an overload taking other arguments. A stage calls it as it + * would call the action itself: the call is a boundary that a restart does not repeat once it is done. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ActionAlias + +/** + * The values the editor offers for a parameter whose type does not say them, such as a string naming a HUD layer. + * [source] is an object; nothing checks the values against it. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Suggestions(val source: KClass) + +/** What the editor calls a parameter of an action and says of it; lang keys or plain text. */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class Param(val title: String = "", val description: String = "") + +/** What [Suggestions] offers. */ +interface SuggestionSource { + val values: List +} + +/** An action that is not undone: an ordinary function editors and `.story` can call by [id]. */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class SimpleAction(val id: String = "") + +@DslMarker +annotation class StoryDsl + +/** How long an action stands in the world, see [temporary]. */ +enum class Lifetime { + /** Taken back when the story passes its checkpoint or ends, and before the segment that performed it repeats. */ + TEMPORARY, + + /** Undone only by a rewind; left in the world when the story ends. */ + RUN, +} + +/** + * The body of an [Action]. [block] declares how to [capture][CaptureScope.capture] the state the action changes, + * how to [undo][CaptureScope.undo] and [fast-forward][ActionScope.fastForward] it, and ends with + * [perform][ActionScope.perform], which does it. + * + * The journal runs [block] again to undo or fast-forward the call, with the saved snapshots in place of every + * capture, so before `perform` it must only declare blocks and compute values. + * + * @param call the receivers and parameters of the action, in order; in scripts the compiler passes them. + */ +suspend fun action(vararg call: Any?, block: suspend ActionScope.() -> Performed): R { + val action = StoryActions.enclosing(block, "action { }") + ActionReplay.take(action)?.let { replay -> + replay.call = call + return ActionScope(null, replay).also { replay.scope = it }.block().value + } + val context = currentCoroutineContext() + val run = context[StoryRun.Element]?.run?.takeIf { context[Replaying] == null } + val args = action.encode(call) + // A stage calling the action directly keeps its record, to finish the action instead of repeating it after a restart. + val site = run?.frames?.lastOrNull()?.takeIf { context[ActiveAction] == null && it.claimsAction() } + val record = run?.let { + val temporary = context[Temporary] + ActionRecord(it, action, args, if (temporary != null) Lifetime.TEMPORARY else Lifetime.RUN, temporary?.until, context[ActiveAction]?.seq, site) + } + return try { + ActionScope(record, null).block().value + } catch (failure: Throwable) { + val unloading = failure is CancellationException && !currentCoroutineContext().isActive || failure is StoryEntityMissing + val missing = if (failure is CancellationException && !unloading) run?.let { unloaded(call) } else null + if (!unloading && missing == null) site?.actionFailed() + throw missing ?: failure + } +} + +/** + * An entity of the call that is no longer the one in the world, without having been destroyed: the body of an NPC + * whose chunk unloaded, or a copy kept by the registry that a new body replaced. Either cancels what it was doing in + * the middle of the action, and the story then waits for the entity to come back. + */ +internal fun unloaded(call: Array): StoryEntityMissing? = call.firstNotNullOfOrNull { value -> + (value as? Entity)?.takeIf { entity -> + val reason = entity.removalReason + if (reason != null) !reason.shouldDestroy() else StoryValues.entities(entity.uuid, entity::class) !== entity + } +}?.let { StoryEntityMissing(it.uuid) } + +/** + * The body of a checkpoint label such as [position]: like [action] without `perform`. A label captures a state + * when its checkpoint is passed and puts it back on every rewind to that checkpoint. + */ +suspend fun label(vararg call: Any?, block: suspend LabelScope.() -> Unit) { + val action = StoryActions.enclosing(block, "label { }") + ActionReplay.take(action)?.let { replay -> LabelScope(replay).also { replay.scope = it }.block(); return } + val checkpoint = call.firstNotNullOfOrNull { it as? CheckpointScope } + ?: throw StoryException("A label is only placed inside checkpoint(name) { }") + val scope = LabelScope(null).also { it.block() } + checkpoint.add(action, action.encode(call), scope.snapshots) +} + +/** + * Runs [block] with every action it performs kept until a checkpoint, like a barricade that only stands during a + * fight: they are taken back when the story passes the checkpoint [until], or the next one of this run when it is + * null, and when the story ends. A segment that repeats after a restart takes them back before it performs them again. + */ +suspend fun temporary(until: String? = null, block: suspend () -> T): T = withContext(Temporary(until)) { block() } + +/** What a capture block can declare: snapshots taken before the action and how to put them back. */ +@StoryDsl +open class CaptureScope internal constructor(internal val replay: ActionReplay?) { + internal val snapshots = CompoundTag() + internal var undo: (UndoScope.() -> Unit)? = null + private var captured = 0 + internal var closed = false + + /** + * Takes a snapshot with [block] before the action changes anything. When the journal undoes the action, it + * returns the saved snapshot instead, so [undo] can use it after a restart too. + */ + inline fun capture(noinline block: () -> S): S = capture(storySerializer(), block) + + @PublishedApi + @Suppress("UNCHECKED_CAST") + internal fun capture(serializer: KSerializer, block: () -> S): S { + check(!closed) { "capture { } must come before perform { }" } + val index = captured++ + replay?.let { return it.snapshot(index, serializer) as S } + val value = block() + snapshots.put(index.toString(), StoryValues.encode(serializer, value)) + return value + } + + /** + * Puts back what the action changed, as the absolute state of its snapshots rather than a reverse delta, so it + * works wherever the action stopped. Call [UndoScope.conflict] when someone else changed it since. + */ + fun undo(block: UndoScope.() -> Unit) { + check(!closed) { "undo { } must come before perform { }" } + undo = block + } +} + +/** The blocks of an [action]. */ +class ActionScope internal constructor( + private val record: ActionRecord?, + replay: ActionReplay?, +) : CaptureScope(replay) { + internal var fastForward: (suspend () -> R)? = null + internal var perform: (suspend () -> R)? = null + + /** Brings the world to where the action ends, at once; without it the action is performed again. */ + fun fastForward(block: suspend () -> R) { + check(!closed) { "fastForward { } must come before perform { }" } + fastForward = block + } + + /** Does the action and ends the body; the journal records the call before [block] starts. */ + suspend fun perform(block: suspend () -> R): Performed { + check(!closed) { "An action performs once" } + closed = true + perform = block + @Suppress("UNCHECKED_CAST") + if (replay != null) return Performed(null as R) + val entry = record?.begin(snapshots) ?: return Performed(block()) + val value = withContext(ActiveAction(entry.seq)) { block() } + record.done(entry) + return Performed(value) + } +} + +/** The blocks of a [label]. */ +class LabelScope internal constructor(replay: ActionReplay?) : CaptureScope(replay) + +/** What an [undo][CaptureScope.undo] block can report. */ +@StoryDsl +class UndoScope internal constructor() { + /** Gives up undoing because someone else changed the state since; the journal logs [reason] as a warning. */ + fun conflict(reason: String): Nothing = throw ActionConflict(reason) +} + +/** The value an action ends with; only [ActionScope.perform] makes one, so the body has to end with it. */ +class Performed internal constructor(internal val value: R) + +internal class ActionConflict(reason: String) : RuntimeException(reason) + +/** + * Writes a call of [action] made in [run] to the journal around its perform; [site] is the frame of the stage that + * calls it directly, which keeps the record to finish the action after a restart. + */ +internal class ActionRecord( + private val run: StoryRun, + private val action: StoryAction, + private val args: CompoundTag, + private val lifetime: Lifetime, + private val until: String?, + private val outer: Long?, + private val site: StoryFrame?, +) { + fun begin(snapshots: CompoundTag): JournalEntry = + run.journal.record(action.id, run.path, lifetime, until, args, snapshots, outer).also { site?.action = it.seq } + + fun done(entry: JournalEntry) { + entry.done = true + } +} + +/** Marks actions performed inside [temporary]. */ +internal class Temporary(val until: String?) : AbstractCoroutineContextElement(Temporary) { + companion object Key : CoroutineContext.Key +} + +/** The action whose perform is running: actions inside it are its parts, which a fast-forward of it repeats. */ +internal class ActiveAction(val seq: Long) : AbstractCoroutineContextElement(ActiveAction) { + companion object Key : CoroutineContext.Key +} + +/** Marks a fast-forward of the journal: the actions it performs again are not recorded twice. */ +internal class Replaying : AbstractCoroutineContextElement(Replaying) { + companion object Key : CoroutineContext.Key +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt new file mode 100644 index 000000000..5e4c8e7b8 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StageLambda.kt @@ -0,0 +1,130 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT + +/** + * A lambda, that compiler turned into a stage: written where a `@State suspend (…) -> T` is expected, its body + * became the stage [stage] of the script [instance] and what it captured became [args]. Its parameters, a + * receiver included, become the following arguments of that stage, saved like any other. + * + * It is called like the lambda it replaced, and it can be saved like any other value. [StageLambda0] to + * [StageLambda8] are its forms for each number of parameters. + * + * @param resultSerializer reads the stage result; recovered through its generated factory after a restart. + */ +abstract class StageLambda( + val instance: String, + val stage: String, + val args: CompoundTag, + private val resultSerializer: KSerializer?, + val arity: Int, +) { + internal val result: KSerializer get() = resultSerializer ?: StoryInstances.resultSerializer(instance, stage) + + /** Calls the stage with [parameters] after the captured arguments. */ + protected suspend fun call(vararg parameters: Any?): Any? = StoryInstances.call(instance, stage, args, parameters) + + /** The first frame of a run that starts with this lambda. */ + internal fun entry(run: StoryRun) = StoryFrame(run, instance, stage).apply { args = this@StageLambda.args.copy() } + + @Serializable + private class Saved( + val instance: String, + val stage: String, + @Serializable(with = ForCompoundNBT::class) val args: CompoundTag, + val arity: Int, + ) + + internal object Serializer : KSerializer { + override val descriptor: SerialDescriptor = SerialDescriptor("story.stage", Saved.serializer().descriptor) + + override fun serialize(encoder: Encoder, value: StageLambda) = + encoder.encodeSerializableValue(Saved.serializer(), Saved(value.instance, value.stage, value.args, value.arity)) + + override fun deserialize(decoder: Decoder): StageLambda = + decoder.decodeSerializableValue(Saved.serializer()).let { of(it.arity, it.instance, it.stage, it.args) } + } + + internal companion object { + /** The largest number of parameters, a receiver included, a stage lambda can take. */ + const val MAX_ARITY = 8 + + fun of(arity: Int, instance: String, stage: String, args: CompoundTag): StageLambda = when (arity) { + 0 -> StageLambda0(instance, stage, args) + 1 -> StageLambda1(instance, stage, args) + 2 -> StageLambda2(instance, stage, args) + 3 -> StageLambda3(instance, stage, args) + 4 -> StageLambda4(instance, stage, args) + 5 -> StageLambda5(instance, stage, args) + 6 -> StageLambda6(instance, stage, args) + 7 -> StageLambda7(instance, stage, args) + 8 -> StageLambda8(instance, stage, args) + else -> throw StoryException("A stage lambda takes at most $MAX_ARITY parameters, not $arity") + } + } +} + +@Suppress("UNCHECKED_CAST") +class StageLambda0(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 0), suspend () -> R { + override suspend fun invoke(): R = call() as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda1(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 1), suspend (P1) -> R { + override suspend fun invoke(p1: P1): R = call(p1) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda2(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 2), suspend (P1, P2) -> R { + override suspend fun invoke(p1: P1, p2: P2): R = call(p1, p2) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda3(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 3), suspend (P1, P2, P3) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3): R = call(p1, p2, p3) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda4(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 4), suspend (P1, P2, P3, P4) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4): R = call(p1, p2, p3, p4) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda5(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 5), suspend (P1, P2, P3, P4, P5) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5): R = call(p1, p2, p3, p4, p5) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda6(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 6), suspend (P1, P2, P3, P4, P5, P6) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6): R = call(p1, p2, p3, p4, p5, p6) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda7(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 7), suspend (P1, P2, P3, P4, P5, P6, P7) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6, p7: P7): R = call(p1, p2, p3, p4, p5, p6, p7) as R +} + +@Suppress("UNCHECKED_CAST") +class StageLambda8(instance: String, stage: String, args: CompoundTag, result: KSerializer? = null) : + StageLambda(instance, stage, args, result, 8), suspend (P1, P2, P3, P4, P5, P6, P7, P8) -> R { + override suspend fun invoke(p1: P1, p2: P2, p3: P3, p4: P4, p5: P5, p6: P6, p7: P7, p8: P8): R = + call(p1, p2, p3, p4, p5, p6, p7, p8) as R +} + +/** The value of a stage lambda, failing clearly when something else was passed where one was expected. */ +internal fun Function<*>.asStage(where: String): StageLambda = this as? StageLambda + ?: throw StoryException("$where takes a lambda written in place, not a function value") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt new file mode 100644 index 000000000..e279272aa --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/State.kt @@ -0,0 +1,64 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +/** + * A stage: a suspend function whose place in a story survives a restart. + * + * Calling a stage returns to the caller like any other call, and the run keeps the whole call stack. Every + * function on that stack continues after a restart from the last stage call or [checkpoint] it passed, with + * its arguments and the locals it still needs restored. Arguments, the result and such locals must therefore be + * serializable; entities are kept as references. + * + * On a function type, as in `block: @State suspend () -> Unit`, it makes the lambda written there a stage of + * its own: what the lambda captures is saved as its arguments. [launchStory] and [async] take their bodies so. + * + * @param name the name saves refer to; the function name when empty. Set it to keep saves working after a rename. + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE) +@Retention(AnnotationRetention.BINARY) +annotation class State(val name: String = "") + +/** + * Ends a segment of the enclosing stage: after a restart the stage continues from here rather than from its + * previous stage call. [rewind] with [name] takes the whole story back to how it was here. Must be called directly + * in a `@State` function, and [name] must be unique within it. + * + * @param restart whether a restart takes the story back here while this is the last checkpoint it passed, as with + * [rewind], instead of going on from where it was: a question asked after it is then asked again. + */ +@Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") +suspend fun checkpoint(name: String, restart: Boolean = false): Unit = + throw StoryException("checkpoint(\"$name\") must be called directly in a @State function") + +/** + * A [checkpoint] that also captures the world with [labels], such as `position(context.player)`. They run once, when + * the checkpoint is passed, and every rewind to it puts back what they captured. + */ +@Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") +suspend fun checkpoint(name: String, restart: Boolean = false, labels: suspend CheckpointScope.() -> Unit): Unit = + throw StoryException("checkpoint(\"$name\") { } must be called directly in a @State function") + +/** Marks the entry the compiler generates for a stage. The runtime calls it to start or resume the stage by [name]. */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageEntry(val name: String) + +/** + * Marks the call the compiler generates for a stage lambda: the runtime calls it with the captured values and the + * parameters of a call, and the stage saves them all as its arguments. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageCall(val name: String) + +/** Names a generated serializer factory for the result of a saved stage lambda. */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageResult(val name: String) + +/** Marks a script class that declares stages with the source [path] its stages are addressed by. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class StageScript(val path: String) + +/** A story could not do what was asked of it. */ +class StoryException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt new file mode 100644 index 000000000..08c268982 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActions.kt @@ -0,0 +1,400 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import io.github.classgraph.ClassGraph +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonLoadedEvent +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import java.io.File +import java.lang.reflect.Method +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlin.reflect.KFunction +import kotlin.reflect.KParameter +import kotlin.reflect.full.callSuspendBy +import kotlin.reflect.jvm.isAccessible +import kotlin.reflect.jvm.kotlinFunction + +/** + * The actions stories can call and journal, by id. A script registers its actions when it starts, an addon when it + * loads. A journal record names its action by id, so after a restart it is undone by whatever declares that id then. + */ +object StoryActions { + private val byId = HashMap() + private val byMethod = HashMap() + private val byBlock = HashMap, StoryAction>() + private val users = HashMap, Int>() + + /** What [all] reads: replaced on every change, so the editor may read it off the game thread. */ + @Volatile + private var snapshot: List = emptyList() + + /** + * Registers the [Action] and [SimpleAction] functions [type] declares under [namespace] until [job] ends; every + * instance of a script registers its class, which stays registered while one of them runs. + */ + fun register(type: Class<*>, namespace: String, job: Job? = null) { + val count = users.merge(type, 1, Int::plus)!! + job?.invokeOnCompletion { release(type) } + if (count > 1) return + val declared = + type.declaredMethods.mapNotNull { declare(it, namespace) }.groupBy { it.id }.mapNotNull { (id, same) -> + if (same.size > 1) HollowEngine.LOGGER.error( + "Actions {} of {} share the id '{}'; give each of them an id of its own", + same.map { it.method.name }, + type.name, + id + ) + same.first() + } + declared.forEach { action -> + val previous = byId.put(action.id, action) + if (previous != null && previous.method.declaringClass.name != type.name) { + HollowEngine.LOGGER.warn( + "Action '{}' of {} replaces the one of {}", + action.id, + type.name, + previous.method.declaringClass.name + ) + } + byMethod[action.method] = action + } + snapshot = byId.values.toList() + } + + private fun release(type: Class<*>) { + val count = users[type] ?: return + if (count > 1) { + users[type] = count - 1 + return + } + users.remove(type) + byMethod.values.filter { it.method.declaringClass == type }.forEach(::remove) + } + + /** The action registered as [id], if any. */ + fun find(id: String): StoryAction? = byId[id] + + /** Every registered action, for editors and commands. */ + val all: List get() = snapshot + + /** Registers the actions of an addon that loaded, found the same way as its event listeners. */ + @SubscribeEvent + fun onAddonLoaded(event: HollowAddonLoadedEvent) = + registerFound(event.classes, event.classLoader, event.packagePrefix, event.id, event.scope.coroutineContext.job) + + /** + * Registers under [namespace] the actions of every class in [classes] (a jar or a directory) within + * [packagePrefix], as an addon's actions are registered when it loads. + */ + fun registerFound(classes: File, classLoader: ClassLoader, packagePrefix: String?, namespace: String, job: Job? = null) { + val names = ClassGraph() + .enableClassInfo().enableAnnotationInfo().enableMethodInfo() + .disableNestedJarScanning().ignoreParentClassLoaders() + .overrideClassLoaders(classLoader).overrideClasspath(classes) + .scan().use { scan -> + (scan.getClassesWithMethodAnnotation(Action::class.java.name).names + scan.getClassesWithMethodAnnotation( + SimpleAction::class.java.name + ).names).distinct() + } + names.filter { packagePrefix == null || it.startsWith(packagePrefix) }.forEach { name -> + runCatching { register(Class.forName(name, false, classLoader), namespace, job) } + .onFailure { HollowEngine.LOGGER.error("Cannot register the actions of {}", name, it) } + } + } + + /** The action whose body [block] is: the function the compiler put the lambda in. */ + internal fun enclosing(block: Any, what: String): StoryAction { + byBlock[block.javaClass]?.let { return it } + val method = block.javaClass.enclosingMethod + val action = method?.let(byMethod::get) ?: throw StoryException( + when { + method == null -> "$what must be the body of an @Action function" + method.isAnnotationPresent(Action::class.java) -> "Action '${method.declaringClass.name}.${method.name}' is not registered; actions register when their script starts or their addon loads" + + else -> "$what must be the body of an @Action function, not of '${method.name}'" + } + ) + byBlock[block.javaClass] = action + return action + } + + /** Undoes what [entry] did; logs why when it cannot. */ + internal fun undo(entry: JournalEntry) { + val replay = try { + StoryValues.withOffWorldNpcs { replay(entry) } + } catch (missing: StoryEntityMissing) { + StoryDeferredReplays.undo(entry, missing.uuid) + return + } + val undo = replay?.scope?.undo ?: return + try { + UndoScope().undo() + } catch (conflict: ActionConflict) { + HollowEngine.LOGGER.warn("Story '{}' leaves '{}' as it is: {}", entry.run, entry.action, conflict.message) + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Story '{}' failed to undo '{}'", entry.run, entry.action, failure) + } + } + + /** Brings the world to where [entry] ended: with its fast-forward, or by performing it again. */ + @Suppress("UNCHECKED_CAST") + internal suspend fun fastForward(entry: JournalEntry) { + val replay = try { + replay(entry) + } catch (missing: StoryEntityMissing) { + StoryDeferredReplays.fastForward(entry, missing.uuid) + return + } + val scope = replay?.scope as? ActionScope ?: return + val block = scope.fastForward ?: scope.perform ?: return + try { + withContext(Replaying()) { block() } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Story '{}' failed to fast-forward '{}'", entry.run, entry.action, failure) + } + } + + /** + * Finishes the action of [entry], which a restart interrupted: performs it again under the same record, with the + * snapshots taken before it first started, and returns its result. + */ + @Suppress("UNCHECKED_CAST") + internal suspend fun resume(entry: JournalEntry): Any? { + val replay = replay(entry) ?: throw StoryException("Story '${entry.run}' cannot finish '${entry.action}'") + val perform = (replay.scope as? ActionScope)?.perform + ?: throw StoryException("Story '${entry.run}' cannot finish '${entry.action}': it performs nothing") + val value = try { + withContext(ActiveAction(entry.seq)) { perform() } + } catch (cancelled: CancellationException) { + throw unloaded(replay.call)?.takeIf { currentCoroutineContext().isActive } ?: cancelled + } + entry.done = true + return value + } + + /** + * Calls the action of [entry] again with its saved receivers and parameters, declaring its blocks only. An entity + * it needs that is not loaded is thrown as [StoryEntityMissing], so the caller can wait for it. + */ + private fun replay(entry: JournalEntry): ActionReplay? { + val action = byId[entry.action] ?: run { + HollowEngine.LOGGER.warn( + "Story '{}' cannot replay '{}': no action has this id now", + entry.run, + entry.action + ) + return null + } + return try { + ActionReplay(entry, action).also(action::replay) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + HollowEngine.LOGGER.warn("Story '{}' cannot replay '{}': {}", entry.run, entry.action, failure.message) + null + } + } + + private fun declare(method: Method, namespace: String): StoryAction? { + val reversible = method.getAnnotation(Action::class.java) + val name = reversible?.id ?: method.getAnnotation(SimpleAction::class.java)?.id ?: return null + // The Kotlin name, not the JVM one: a parameter of a value class such as Duration mangles the latter. + val id = name.ifBlank { kebabCase(method.kotlinFunction?.name ?: method.name) } + .let { if (':' in it) it else "$namespace:$it" } + return runCatching { + StoryAction( + id, + method, + reversible != null + ) + }.onFailure { + HollowEngine.LOGGER.error( + "Action '{}' of {} cannot be used: {}", + id, + method.declaringClass.name, + it.message + ) + }.getOrNull() + } + + private fun remove(action: StoryAction) { + if (byId[action.id] === action) byId.remove(action.id) + byMethod.remove(action.method) + byBlock.values.removeIf { it === action } + snapshot = byId.values.toList() + } + + /** `placeBlock` as `place-block`, the way scenes name commands. */ + private fun kebabCase(name: String): String = + name.replace(Regex("(?<=[a-z0-9])(?=[A-Z])"), "-").lowercase() +} + +/** + * A function registered as an action: how its receivers and parameters are written to a journal record and read + * back to call it again. + */ +class StoryAction internal constructor(val id: String, internal val method: Method, val reversible: Boolean) { + private enum class Kind { SCRIPT, OBJECT, CHECKPOINT, UNSAVED, VALUE } + + private class Parameter( + val reflected: KParameter, + val name: String, + val kind: Kind, + val serializer: KSerializer? = null, + ) + + internal val function: KFunction<*> = + method.kotlinFunction ?: throw StoryException("'${method.name}' is not a Kotlin function") + private val parameters: List by lazy { function.parameters.map(::parameter) } + + init { + if (reversible && !function.isSuspend) throw StoryException("Action '$id' must be a suspend function") + function.isAccessible = true + if (reversible) parameters + } + + /** The parameter names of the function, receivers included, as a record keeps them. */ + val parameterNames: List get() = parameters.map { it.name } + + /** What the function is a member of: its object or a running instance of its script; null at the top level. */ + internal fun owner(): Any? { + val owner = method.declaringClass + owner.kotlin.objectInstance?.let { return it } + if (!owner.isAnnotationPresent(StageScript::class.java)) return null + return StoryInstances.instanceOf(owner) ?: throw StoryException("Action '$id' belongs to a script that is not running") + } + + internal fun encode(call: Array): CompoundTag { + if (call.size != parameters.size) throw StoryException( + "action { } of '$id' takes the ${parameters.size} receivers and parameters of its function in order, not ${call.size}" + ) + val tag = CompoundTag() + parameters.forEachIndexed { index, parameter -> + val value = call[index] + when (parameter.kind) { + Kind.SCRIPT -> tag.putString( + parameter.name, + StoryInstances.keyOf(value ?: throw StoryException("'$id' got no script")) + ) + + Kind.VALUE -> StoryValues.put(tag, parameter.name, parameter.serializer!!, value) + Kind.OBJECT, Kind.CHECKPOINT, Kind.UNSAVED -> Unit + } + } + return tag + } + + /** + * Calls the function again with what [replay] recorded, unsaved parameters left to their defaults; its body + * declares the blocks into [replay] and returns without suspending. + */ + internal fun replay(replay: ActionReplay) { + val arguments = decode(replay.entry.args) + var outcome: Result? = null + ActionReplay.begin(replay) + try { + suspend { function.callSuspendBy(arguments) }.startCoroutine(Continuation(EmptyCoroutineContext) { + outcome = it + }) + } finally { + ActionReplay.end() + } + (outcome ?: throw StoryException("'$id' suspended before its action { } declared its blocks")).getOrThrow() + if (replay.scope == null) throw StoryException("'$id' did not reach its action { }") + } + + private fun decode(tag: CompoundTag): Map = + parameters.filter { it.kind != Kind.UNSAVED }.associate { parameter -> + parameter.reflected to when (parameter.kind) { + Kind.SCRIPT -> tag.getString(parameter.name) + .let { StoryInstances.find(it) ?: throw StoryException("Script '$it' is not running") } + + Kind.OBJECT -> method.declaringClass.kotlin.objectInstance + Kind.CHECKPOINT -> CheckpointScope.REPLAY + Kind.VALUE -> StoryValues.get(tag, parameter.name, parameter.serializer!!) + Kind.UNSAVED -> null + } + } + + private fun parameter(parameter: KParameter): Parameter { + val name = if (parameter.kind == KParameter.Kind.EXTENSION_RECEIVER) "this" else parameter.name + ?: "argument${parameter.index}" + return when { + parameter.kind == KParameter.Kind.INSTANCE -> { + val owner = method.declaringClass + when { + owner.isAnnotationPresent(StageScript::class.java) -> Parameter( + parameter, + "this\$script", + Kind.SCRIPT + ) + + owner.kotlin.objectInstance != null -> Parameter(parameter, "this\$object", Kind.OBJECT) + else -> throw StoryException("Action '$id' must be declared in a script, an object or at the top level") + } + } + + parameter.kind != KParameter.Kind.EXTENSION_RECEIVER && parameter.kind != KParameter.Kind.VALUE -> throw StoryException( + "Action '$id' cannot take context parameters" + ) + + parameter.type.classifier == CheckpointScope::class -> Parameter(parameter, name, Kind.CHECKPOINT) + parameter.annotations.any { it is Unsaved } -> { + if (!parameter.isOptional) throw StoryException("The unsaved '$name' of '$id' needs a default value for the journal to call it with") + Parameter(parameter, name, Kind.UNSAVED) + } + + else -> { + val serializer = try { + StoryValues.serializer(parameter.type) + } catch (failure: StoryException) { + throw StoryException("The journal keeps the parameters of '$id', but '$name' cannot be kept: ${failure.message}") + } + Parameter(parameter, name, Kind.VALUE, serializer) + } + } + } +} + +/** A call of an action made again by the journal: the record it replays and the blocks its body declared. */ +internal class ActionReplay(val entry: JournalEntry, val action: StoryAction) { + var scope: CaptureScope? = null + + /** The receivers and parameters the call was made with again. */ + var call: Array = emptyArray() + + fun snapshot(index: Int, serializer: KSerializer): Any? { + val tag = entry.snapshots.get(index.toString()) + ?: throw StoryException("The record of '${action.id}' has no snapshot #$index: the action captures more than it did then") + return StoryValues.decode(serializer, tag) + } + + companion object { + private val CURRENT = ThreadLocal() + + fun begin(replay: ActionReplay) = CURRENT.set(replay) + + fun end() = CURRENT.remove() + + /** The replay [action] runs in, if its body is being called again by the journal. */ + fun take(action: StoryAction): ActionReplay? { + val replay = CURRENT.get() ?: return null + CURRENT.remove() + if (replay.action !== action) throw StoryException( + "'${replay.action.id}' reached the action { } of '${action.id}'; the body of an action must be its own action { }" + ) + return replay + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt new file mode 100644 index 000000000..2b6537ba7 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryApi.kt @@ -0,0 +1,187 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.selects.select +import kotlinx.serialization.KSerializer +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import kotlin.jvm.internal.CallableReference +import kotlin.jvm.internal.ClassBasedDeclarationContainer +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * Starts the story [key] of the server with [block] as its first stage and returns its handle. A story already + * running under [key] is returned instead, so a trigger may call this every time it fires. + */ +fun MinecraftServer.launchStory(key: String, block: @State suspend () -> Unit): StoryHandle = + StoryOwners.server(this).launch(key, block) + +/** Starts the story [key] of this entity; it sleeps while the entity is unloaded and ends with it. */ +fun Entity.launchStory(key: String, block: @State suspend () -> Unit): StoryHandle = + StoryOwners.entity(this).launch(key, block) + +/** Starts the story [key] of this node; it sleeps while the node does not run. */ +fun NodeScript.launchStory(key: String, block: @State suspend () -> Unit): StoryHandle = + StoryOwners.node(this).launch(key, block) + +/** Stops the story [key] of the server; returns whether it was running. */ +fun MinecraftServer.stopStory(key: String): Boolean = StoryOwners.server(this).stop(key) + +fun Entity.stopStory(key: String): Boolean = StoryOwners.entity(this).stop(key) + +fun NodeScript.stopStory(key: String): Boolean = StoryOwners.node(this).stop(key) + +/** + * Rewinds the story [key] of the server, see [rewind]: to the checkpoint [checkpoint], or to the latest checkpoint + * the story passed when it is null. Returns whether there is such a story. + */ +fun MinecraftServer.rewindStory(key: String, checkpoint: String? = null): Boolean = + StoryOwners.server(this)[key]?.rewind(checkpoint) != null + +fun Entity.rewindStory(key: String, checkpoint: String? = null): Boolean = + StoryOwners.entity(this)[key]?.rewind(checkpoint) != null + +fun NodeScript.rewindStory(key: String, checkpoint: String? = null): Boolean = + StoryOwners.node(this)[key]?.rewind(checkpoint) != null + +/** + * Takes the story back and does not return. Without [checkpoint] this run and its branches go back to the latest + * checkpoint this run passed, its start if there was none; with it the whole story goes back to the latest + * checkpoint so named, branches started since included. The actions performed since are undone. What [once] and + * [remember] kept stays as it is. + */ +suspend fun rewind(checkpoint: String? = null): Nothing { + currentRun("rewind()").rewind(checkpoint) + awaitCancellation() +} + +/** What the current stage knows about its story: whom it runs for. */ +val context: StoryContext + get() = (StoryRun.current() ?: throw StoryException("context is only available while a story runs")).context + +/** + * Starts the branch [name] of the current story and returns its handle. The branch is saved with the story and + * ends when the stage that started it returns. After a restart the same call returns the branch already running. + * Cancel its handle before reusing the name for a new branch; cancellation finishes before the replacement starts. + */ +suspend fun async(name: String, block: @State suspend () -> T): StoryHandle { + val run = currentRun("async(\"$name\")") + val lambda = block.asStage("async(\"$name\")") + return StoryHandle(run.branch(name, lambda), lambda.result) +} + +/** + * A run of a story or of one of its branches. A story can keep it like any other value: it is saved as the path of + * the run and found again in the same story when read. + */ +class StoryHandle internal constructor( + internal val run: StoryRun, + private val result: KSerializer?, +) { + val key: String get() = run.path + + val isActive: Boolean get() = !run.state.done && !run.isStopped + + /** Where the run is: its stages and their positions. */ + val state: String get() = run.describe() + + /** Waits for the run to end and returns its result; throws if it failed or was stopped. */ + @Suppress("UNCHECKED_CAST") + suspend fun await(): T = when (run.awaitDone()) { + StoryRun.Status.COMPLETED -> { + val saved = run.result + if (result == null || saved == null) Unit as T else StoryValues.decode(result, saved) as T + } + StoryRun.Status.FAILED -> throw StoryException("Story '$key' failed: ${run.failure}") + else -> throw CancellationException("Story '$key' was stopped") + } + + /** Waits for the run to end without looking at how it ended. */ + suspend fun join() { + run.awaitDone() + } + + /** Stops this run and releases a branch name for reuse, including when the branch has already completed. */ + fun cancel() = run.stop() + + /** Rewinds the run from outside, see [rewind]. */ + fun rewind(checkpoint: String? = null) = run.rewind(checkpoint) + + + /** The checkpoints the story can be rewound to, oldest first, with the run that passed each. */ + val checkpoints: List> get() = run.history.all.map { it.name to it.run } +} + +/** Waits for every one of [handles]. */ +suspend fun all(vararg handles: StoryHandle<*>) = handles.forEach { it.join() } + +/** Waits for the first of [handles] to end, stops the others and returns the one that ended. */ +suspend fun race(vararg handles: StoryHandle<*>): StoryHandle<*> { + require(handles.isNotEmpty()) { "race() needs at least one branch" } + val winner = awaitFirst(handles.toList()) + handles.filter { it.run !== winner.run }.forEach(StoryHandle<*>::cancel) + return winner +} + +/** Waits without changing the supplied runs; only the temporary waiter coroutines belong to this scope. */ +private suspend fun awaitFirst(handles: List>): StoryHandle<*> = coroutineScope { + val waits = handles.map { handle -> async { handle.join(); handle } } + try { + select { waits.forEach { wait -> wait.onAwait { it } } } + } finally { + waits.forEach { it.cancel() } + } +} + +/** Waits for [count] distinct runs in [handles] to end, stops the rest and returns those that ended. */ +suspend fun any(count: Int, vararg handles: StoryHandle<*>): List> { + val remaining = handles.distinctBy { it.run }.toMutableList() + require(count in 0..remaining.size) { "Cannot wait for $count of ${remaining.size} distinct branches" } + val ended = ArrayList>() + while (ended.size < count) { + val winner = awaitFirst(remaining) + ended += winner + remaining.remove(winner) + } + remaining.forEach(StoryHandle<*>::cancel) + return ended +} + +/** + * A variable of the story that reads it: every run keeps its own value and saves it, branches share their + * story's. Declare it at the top level of a script: `var trust by saved(0)`. + */ +inline fun saved(default: T): ReadWriteProperty = SavedVariable(default, storySerializer()) + +@PublishedApi +internal class SavedVariable(internal val default: T, internal val serializer: KSerializer) : ReadWriteProperty { + @Suppress("UNCHECKED_CAST") + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + val tag = run(property).vars.get(name(property)) ?: return default + return StoryValues.decode(serializer, tag) as T + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + val run = run(property) + StoryValues.put(run.vars, name(property), serializer, value) + } + + private fun run(property: KProperty<*>) = + StoryRun.current() ?: throw StoryException("'${property.name}' is a story variable and only exists while a story runs") + + /** The property qualified by the script declaring it, which a script property reference knows as its owner. */ + private fun name(property: KProperty<*>): String { + val owner = ((property as? CallableReference)?.owner as? ClassBasedDeclarationContainer)?.jClass + val script = owner?.let { StoryInstances.scriptPathOf(it) ?: it.name } + return script?.let { "$it/${property.name}" } ?: property.name + } +} + +internal suspend fun currentRun(what: String): StoryRun = + currentCoroutineContext()[StoryRun.Element]?.run ?: throw StoryException("$what is only available while a story runs") diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryArguments.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryArguments.kt new file mode 100644 index 000000000..59b027c4a --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryArguments.kt @@ -0,0 +1,44 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.core.BlockPos +import net.minecraft.core.registries.Registries +import net.minecraft.resources.ResourceKey +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.LivingEntity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.addons.narrative.scene.DialogueCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.speech.audience +import ru.hollowhorizon.hollowengine.common.utils.currentServer +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** What the fields of a story card compile to: values written the way a writer types them. */ + +/** `1.5s`, `500ms`, or a bare number of milliseconds. */ +fun duration(text: String): Duration = text.toDoubleOrNull()?.milliseconds ?: Duration.parse(text) + +fun vec(x: Number, y: Number, z: Number) = Vec3(x.toDouble(), y.toDouble(), z.toDouble()) + +fun blockPos(x: Int, y: Int, z: Int) = BlockPos(x, y, z) + +fun location(text: String): ResourceLocation = ResourceLocation.tryParse(text) + ?: throw StoryException("'$text' is not an id like minecraft:stone") + +fun dimension(text: String): ServerLevel = currentServer.getLevel(ResourceKey.create(Registries.DIMENSION, location(text))) + ?: throw StoryException("No dimension is named '$text'") + +fun character(name: String): DialogueCharacter = DialogueCharacter.of(name) + +/** The player of the scene named [name], or its first one for `player`. */ +suspend fun actor(name: String): LivingEntity { + val players = audience().orEmpty() + val found = if (name.equals(PLAYER, ignoreCase = true)) players.firstOrNull() + else players.firstOrNull { it.scoreboardName == name } + return found ?: throw StoryException("No player of the scene is named '$name'") +} + +/** The level of the first player of the scene, or the overworld. */ +suspend fun storyLevel(): ServerLevel = audience()?.firstOrNull()?.serverLevel() ?: currentServer.overworld() + +private const val PLAYER = "player" diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt new file mode 100644 index 000000000..40e0acc56 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryBoxes.kt @@ -0,0 +1,100 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.minecraft.nbt.CompoundTag + +/** + * A `var` of a stage that `@State` lambdas captured. The compiler turns the var into a box, so the stage and its + * lambdas read and write one value, and the story saves that value once, with the stage that declared it. + */ +class StoryBox internal constructor(internal val id: String, var value: T) + +/** Where the compiler gets the boxes of shared vars from. */ +object StoryBoxes { + /** + * The box of the var [name] the current stage declares, set to [initial]. The same declaration gets the same box + * when its segment repeats after a restart, so lambdas started before it keep sharing the value. + */ + @JvmStatic + fun box(name: String, serializer: KSerializer, initial: T): StoryBox { + val run = StoryRun.current() ?: throw StoryException("var $name is shared with a @State lambda, which only works while a story runs") + val frame = run.frames.lastOrNull() ?: throw StoryException("var $name is shared with a @State lambda outside a stage") + return run.root.boxes.boxFor(frame, name, serializer, initial) + } +} + +/** The boxes of shared vars of one story, saved with its tree. */ +internal class StoryBoxTable { + private class Live(val box: StoryBox, val serializer: KSerializer) + + private val live = LinkedHashMap() + private var saved = CompoundTag() + private var next = 0L + + @Suppress("UNCHECKED_CAST") + fun boxFor(frame: StoryFrame, name: String, serializer: KSerializer, initial: T): StoryBox { + val key = "box\$$name#${frame.nextBoxOrdinal(name)}" + val id = frame.segment.getString(key).ifEmpty { + "${frame.stage}.$name#${next++}".also { id -> + frame.segment.putString(key, id) + frame.boxes += id + } + } + val box = live[id]?.box ?: StoryBox(id, initial).also { live[id] = Live(it, serializer) } + saved.remove(id) + box.value = initial + return box as StoryBox + } + + /** The box [id] a lambda or a restored local refers to. */ + fun resolve(id: String, serializer: KSerializer): StoryBox { + live[id]?.let { return it.box } + val tag = saved.get(id) ?: throw StoryException( + "A @State lambda refers to a var that is gone: the stage that declared it returned, or it belongs to another story" + ) + val box = StoryBox(id, StoryValues.decode(serializer, tag)) + live[id] = Live(box, serializer) + saved.remove(id) + return box + } + + /** The stage that declared [ids] returned. */ + fun release(ids: Collection) { + ids.forEach { id -> + live.remove(id) + saved.remove(id) + } + } + + fun save(): CompoundTag = CompoundTag().apply { + putLong("next", next) + val values = saved.copy() + live.forEach { (id, entry) -> values.put(id, StoryValues.encode(entry.serializer, entry.box.value)) } + put("values", values) + } + + /** Puts back what [save] wrote; boxes are made again as the code that uses them asks for them. */ + fun load(tag: CompoundTag) { + live.clear() + saved = tag.getCompound("values").copy() + next = maxOf(next, tag.getLong("next")) + } +} + +/** A box is written as its id; reading it finds the box the story keeps under that id. */ +internal class StoryBoxSerializer(private val value: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.box", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: StoryBox) = encoder.encodeString(value.id) + + override fun deserialize(decoder: Decoder): StoryBox { + val id = decoder.decodeString() + val run = StoryRun.current() ?: throw StoryException("A shared var is only read while its story runs") + return run.root.boxes.resolve(id, value) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt new file mode 100644 index 000000000..b8534eef6 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryCommands.kt @@ -0,0 +1,172 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.builder.ArgumentBuilder +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.suggestion.SuggestionProvider +import com.mojang.brigadier.tree.CommandNode +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.commands.SharedSuggestionProvider +import net.minecraft.commands.arguments.EntityArgument +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import ru.hollowhorizon.hollowengine.common.utils.mcTranslate + +/** + * What the stories of an owner do, and starting, stopping and rewinding + * them by hand, as a game master or an author testing a story does. + */ +internal object StoryCommands { + private typealias Context = CommandContext + + fun register(root: CommandNode) { + val story = Commands.literal("story").requires { it.hasPermission(2) } + .then(actions(Commands.literal("server")) { StoryOwners.server(it.source.server) }) + .then( + Commands.literal("entity").then( + actions(Commands.argument("entity", EntityArgument.entity())) { + StoryOwners.entity(EntityArgument.getEntity(it, "entity")) + }, + ), + ) + .then( + Commands.literal("node").then( + actions(Commands.argument("node", StringArgumentType.string()).suggests(nodes)) { + val path = StringArgumentType.getString(it, "node") + StoryOwners.serverNode(path) ?: throw StoryException("No node '$path' runs on the server") + }, + ), + ) + root.addChild(story.build()) + } + + private fun > actions(builder: T, owner: (Context) -> StoryRuns): T { + val keys = SuggestionProvider { context, suggestions -> + SharedSuggestionProvider.suggest(runCatching { owner(context).keys }.getOrDefault(emptySet()).map(::quoted), suggestions) + } + val checkpoints = SuggestionProvider { context, suggestions -> + val names = runCatching { owner(context)[key(context)]?.checkpoints?.map { it.first } }.getOrNull().orEmpty() + SharedSuggestionProvider.suggest(names.distinct().map(::quoted), suggestions) + } + fun key() = Commands.argument("key", StringArgumentType.string()).suggests(keys) + return builder + .then(Commands.literal("list").executes { run(it) { list(it, owner(it)) } }) + .then( + Commands.literal("start").then( + Commands.argument("story", StringArgumentType.string()).suggests(stories).then( + Commands.argument("key", StringArgumentType.string()) + .executes { run(it) { start(it, owner(it), null) } } + .then( + Commands.argument("stage", StringArgumentType.string()).suggests(stages) + .executes { run(it) { start(it, owner(it), StringArgumentType.getString(it, "stage")) } }, + ), + ), + ), + ) + .then(Commands.literal("stop").then(key().executes { run(it) { stop(it, owner(it)) } })) + .then( + Commands.literal("rewind").then( + key().executes { run(it) { rewind(it, owner(it), null) } }.then( + Commands.argument("checkpoint", StringArgumentType.string()).suggests(checkpoints) + .executes { run(it) { rewind(it, owner(it), StringArgumentType.getString(it, "checkpoint")) } }, + ), + ), + ) + .then(Commands.literal("checkpoints").then(key().executes { run(it) { checkpoints(it, owner(it)) } })) + } + + private fun list(context: Context, runs: StoryRuns): Int { + if (runs.keys.isEmpty()) { + context.source.sendSuccess({ "hollowengine.commands.story.none".mcTranslate }, false) + return 0 + } + runs.keys.forEach { key -> + val state = runs[key]?.state ?: return@forEach + context.source.sendSuccess({ "hollowengine.commands.story.entry".mcTranslate(key, state) }, false) + } + return runs.keys.size + } + + /** + * Starts a `.story` from its `main` or [stage], played to the player the stories belong to or else to whoever runs + * the command, or a stage of a running script: [stage], or the only one it declares. + */ + private fun start(context: Context, runs: StoryRuns, stage: String?): Int { + val key = key(context) + val story = StringArgumentType.getString(context, "story") + if (story.endsWith(STORY_EXTENSION)) { + val player = runs.owner.entity as? ServerPlayer ?: context.source.player + ?: throw StoryException("A scene is played to a player: start it for one or as one") + runs.launchScene(key, if (stage == null) story else "$story#$stage", listOf(player)) + context.source.sendSuccess({ "hollowengine.commands.story.started_scene".mcTranslate(key, story) }, true) + return 1 + } + val stages = StoryInstances.stagesOf(story) + val chosen = stage ?: stages.singleOrNull() + ?: throw StoryException("Script '$story' has the stages ${stages.sorted()}; name one after the key") + runs.launch(key, story, chosen) + context.source.sendSuccess({ "hollowengine.commands.story.started".mcTranslate(key, chosen) }, true) + return 1 + } + + private fun stop(context: Context, runs: StoryRuns): Int { + val key = key(context) + if (!runs.stop(key)) return missing(context, key) + context.source.sendSuccess({ "hollowengine.commands.story.stopped".mcTranslate(key) }, true) + return 1 + } + + private fun rewind(context: Context, runs: StoryRuns, checkpoint: String?): Int { + val key = key(context) + val story = runs[key] ?: return missing(context, key) + story.rewind(checkpoint) + val message = if (checkpoint == null) "hollowengine.commands.story.rewound_latest".mcTranslate(key) + else "hollowengine.commands.story.rewound".mcTranslate(key, checkpoint) + context.source.sendSuccess({ message }, true) + return 1 + } + + private fun checkpoints(context: Context, runs: StoryRuns): Int { + val key = key(context) + val story = runs[key] ?: return missing(context, key) + story.checkpoints.forEachIndexed { index, (name, run) -> + context.source.sendSuccess({ "hollowengine.commands.story.checkpoint".mcTranslate(index + 1, name, run) }, false) + } + return story.checkpoints.size + } + + private fun missing(context: Context, key: String): Int { + context.source.sendFailure("hollowengine.commands.story.missing".mcTranslate(key)) + return 0 + } + + /** Runs [command], telling the source why a story refused instead of failing the command with a stack trace. */ + private fun run(context: Context, command: () -> Int): Int = try { + command() + } catch (failure: StoryException) { + context.source.sendFailure("hollowengine.commands.story.failed".mcTranslate(failure.message.orEmpty())) + 0 + } + + private fun key(context: Context): String = StringArgumentType.getString(context, "key") + + private fun quoted(value: String): String = StringArgumentType.escapeIfRequired(value) + + private const val STORY_EXTENSION = StoryScripts.EXTENSION + + /** The running scripts and the `.story` files. */ + private val stories = SuggestionProvider { _, suggestions -> + val files = ScriptRegistry.list(STORY_EXTENSION).map(ScriptRegistry::display) + SharedSuggestionProvider.suggest((StoryInstances.registered + files).distinct().map(::quoted), suggestions) + } + + private val stages = SuggestionProvider { context, suggestions -> + val script = runCatching { StringArgumentType.getString(context, "story") }.getOrNull() + SharedSuggestionProvider.suggest(script?.let(StoryInstances::stagesOf).orEmpty().map(::quoted), suggestions) + } + + private val nodes = SuggestionProvider { _, suggestions -> + SharedSuggestionProvider.suggest(StoryOwners.serverNodes.map(::quoted), suggestions) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryData.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryData.kt new file mode 100644 index 000000000..ff0238790 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryData.kt @@ -0,0 +1,46 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.LivingEntity +import ru.hollowhorizon.hollowengine.addons.narrative.speech.audience +import ru.hollowhorizon.hollowengine.common.utils.currentServer + +/** The player the story is shown to: the first of its audience, or the first on the server. */ +suspend fun storyPlayer(): ServerPlayer = audience()?.firstOrNull() ?: currentServer.playerList.players.firstOrNull() +?: throw StoryException("No player is around to read") + +/** The player called [name]. */ +fun findStoryPlayer(name: String): ServerPlayer = + currentServer.playerList.getPlayerByName(name) ?: throw StoryException("There is no player '$name'") + +fun entityHealth(entity: Entity): Double = (entity as? LivingEntity)?.health?.toDouble() ?: 0.0 + +fun entityMaxHealth(entity: Entity): Double = (entity as? LivingEntity)?.maxHealth?.toDouble() ?: 0.0 + +/** The value of the attribute [attribute], such as `minecraft:generic.movement_speed`, or 0 for an entity without it. */ +fun entityAttribute(entity: Entity, attribute: String): Double { + val living = entity as? LivingEntity ?: return 0.0 + val holder = BuiltInRegistries.ATTRIBUTE.getHolder(ResourceLocation.parse(attribute)).orElse(null) + ?: throw StoryException("There is no attribute '$attribute'") + return living.getAttribute(holder)?.value ?: 0.0 +} + +fun playerFood(player: Entity): Double = (player as? ServerPlayer)?.foodData?.foodLevel?.toDouble() ?: 0.0 + +fun playerLevel(player: Entity): Double = (player as? ServerPlayer)?.experienceLevel?.toDouble() ?: 0.0 + +fun playerGameMode(player: Entity): String = (player as? ServerPlayer)?.gameMode?.gameModeForPlayer?.name?.lowercase().orEmpty() + +/** The id of what [player] holds in the main hand. */ +fun playerHeldItem(player: Entity): String = + (player as? ServerPlayer)?.mainHandItem?.let { BuiltInRegistries.ITEM.getKey(it.item).toString() }.orEmpty() + +/** How many of the item [item] the inventory of [player] holds. */ +fun playerItemCount(player: Entity, item: String): Double { + val inventory = (player as? ServerPlayer)?.inventory ?: return 0.0 + val wanted = BuiltInRegistries.ITEM.get(ResourceLocation.parse(item)) + return inventory.items.filter { it.item == wanted }.sumOf { it.count }.toDouble() +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt new file mode 100644 index 000000000..e2fa8b61b --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryDeferredReplays.kt @@ -0,0 +1,80 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.launch +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext +import java.util.UUID + +/** + * Undos and fast-forwards that need an entity which is not loaded: a player who is offline, a mob in an unloaded + * chunk. Skipping them would leave the world as the story no longer is, so they wait for the entity and run when it + * loads. They are kept with the server, apart from any story, so a story that ends meanwhile still gets them done. + */ +internal object StoryDeferredReplays { + private enum class Kind { UNDO, FAST_FORWARD } + + private class Deferred(val kind: Kind, val entry: JournalEntry) + + private val waiting = LinkedHashMap>() + + /** What [save] wrote for this server, changed since: the owner of the queue should save it. */ + var changed = false + private set + + fun undo(entry: JournalEntry, entity: UUID) = defer(Kind.UNDO, entry, entity) + + fun fastForward(entry: JournalEntry, entity: UUID) = defer(Kind.FAST_FORWARD, entry, entity) + + /** [entity] loaded: runs what waited for it, in the order it was deferred. */ + fun entityLoaded(entity: Entity) { + val deferred = waiting.remove(entity.uuid) ?: return + changed = true + deferred.forEach { replay -> + when (replay.kind) { + Kind.UNDO -> StoryActions.undo(replay.entry) + Kind.FAST_FORWARD -> { + val server = entity.server ?: return@forEach + server.runtimeContext.scope.launch { StoryActions.fastForward(replay.entry) } + } + } + } + } + + fun save(): CompoundTag = CompoundTag().apply { + waiting.forEach { (uuid, deferred) -> + put(uuid.toString(), ListTag().apply { + deferred.forEach { replay -> + add(replay.entry.save().apply { putString("deferred", replay.kind.name) }) + } + }) + } + changed = false + } + + fun load(tag: CompoundTag) { + waiting.clear() + tag.allKeys.forEach { key -> + val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@forEach + waiting[uuid] = tag.getList(key, Tag.TAG_COMPOUND.toInt()).mapTo(ArrayList()) { + val saved = it as CompoundTag + Deferred(runCatching { Kind.valueOf(saved.getString("deferred")) }.getOrDefault(Kind.UNDO), JournalEntry.load(saved)) + } + } + changed = false + } + + fun clear() { + waiting.clear() + changed = false + } + + private fun defer(kind: Kind, entry: JournalEntry, entity: UUID) { + HollowEngine.LOGGER.info("Story '{}' waits for entity {} to {} '{}'", entry.run, entity, kind.name.lowercase(), entry.action) + waiting.getOrPut(entity, ::ArrayList) += Deferred(kind, entry) + changed = true + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt new file mode 100644 index 000000000..03c72dc1c --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrame.kt @@ -0,0 +1,410 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.StringTag +import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.HollowEngine + +/** + * One call of a stage on a story's stack. + * + * The compiler rewrites every `@State` function to drive its frame: the stage enters it, saves the locals it + * still needs at each stage call and [checkpoint], and on a restart is led back to where it was ([resumesInto]). + * A frame is saved at those boundaries only, so a stage that restarts repeats the segment it was in. + * + * The positions the generated code passes around are `site * 2` for "inside the call at site" and + * `site * 2 + 1` for "after it"; the save names sites instead, so it survives the code being recompiled. + */ +class StoryFrame internal constructor( + internal val run: StoryRun, + val instance: String, + val stage: String, +) { + internal var hash = 0 + internal var position: String? = null + internal var args = CompoundTag() + internal var locals = CompoundTag() + internal var result: Tag? = null + internal var segment = CompoundTag() + set(value) { + field = value + boxOrdinals.clear() + } + + /** The boxes of shared vars this frame declared; they go when it returns. */ + internal val boxes = LinkedHashSet() + + /** How many times each shared var was declared in this segment since the frame last started running it. */ + private val boxOrdinals = HashMap() + internal val children = LinkedHashSet() + internal var trampolined = false + + /** The journal mark when the frame was entered: its temporary actions are those after it. */ + internal var entryMark = 0L + + /** The journal mark when the current segment started, which a repeat of the segment and `rewind()` go back to. */ + internal var segmentMark = 0L + private var sites: Array = emptyArray() + private var resuming = false + private var target = -1 + private var replaced = false + private var failure: Throwable? = null + + /** Where the frame was before the call in progress: a call that fails returns the frame there. */ + private var previous: CompoundTag? = null + + /** + * The journal record of the action called at the current position, which a restart in the middle of it finishes; + * [NO_RECORD] until the action writes it, null when no action is being called. + */ + internal var action: Long? = null + + /** Whether the stage is still finding its way back to the position it was saved at. */ + fun isResuming(): Boolean = resuming + + /** Whether code holding the positions [from]..[to] runs: always, unless the stage is resuming elsewhere. */ + fun enters(from: Int, to: Int): Boolean = !resuming || target in from..to + + /** Whether the stage is resuming to one of the positions [from]..[to]. */ + fun resumesInto(from: Int, to: Int): Boolean = resuming && target in from..to + + /** Whether the stage is resuming exactly to [position]. */ + fun isResumingAt(position: Int): Boolean = resuming && target == position + + /** + * Remembers an argument, which the stage is started with again after a restart. A saved frame that code + * enters with other arguments is not the one it was saved as, so the stage starts over. + */ + fun saveArg(name: String, serializer: KSerializer, value: Any?) { + val encoded = StoryValues.encode(serializer, value) + val saved = args.get(name) + if (resuming && saved != null && saved != encoded) startOver("'$stage' was entered with another '$name'") + args.put(name, encoded) + } + + /** + * The key [once] and [remember] keep [name] under: the stage with its arguments, the segment it is in, the name. + * Long arguments are shortened to their hash. + */ + internal fun recordKey(name: String): String { + val arguments = args.toString().removeSurrounding("{", "}") + val shown = if (arguments.length <= MAX_ARGUMENTS_IN_KEY) arguments else Integer.toHexString(arguments.hashCode()) + return "$instance/$stage($shown)/${position ?: "start"}/$name" + } + + /** Reached only if a stage that returns a value ran off its end, which the compiler rules out. */ + fun lost(): Nothing = throw StoryException("Stage '$stage' ended without returning a value") + + /** Remembers a local that outlives the next boundary. */ + fun save(name: String, serializer: KSerializer, value: Any?) { + StoryValues.put(locals, name, serializer, value) + } + + /** + * The saved value of a local, or [current] when the boundary resumed to did not need it. A local that no + * longer reads as its type, after its declaration changed, makes the stage start over. + */ + fun restore(name: String, serializer: KSerializer, current: Any?): Any? { + val tag = locals.get(name) ?: return current + return try { + StoryValues.decode(serializer, tag) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + startOver("its local '$name' no longer reads as ${serializer.descriptor.serialName}: ${failure.message}") + current + } + } + + /** The stage is about to call the stage at [site]; the locals the call needs are saved right after. */ + fun beginCall(site: Int) { + run.discardPending("'$stage' moved on") + previous = CompoundTag().apply { + position?.let { putString("position", it) } + put("locals", locals.copy()) + result?.let { put("result", it) } + } + position = CALL + sites[site] + result = null + run.lastResult = null + run.enterTrampolined = true + } + + /** The call at [site] returned [value]; runs any tail call it left and returns the final value. */ + suspend fun completeCall(site: Int, value: Any?): Any? { + var current = value + while (true) { + val tail = run.tail ?: break + run.tail = null + run.enterTrampolined = true + current = StoryInstances.invoke(tail.instance, tail.stage, tail.args) + } + position = AFTER + sites[site] + previous = null + result = run.lastResult + run.lastResult = null + segment = CompoundTag() + segmentMark = run.journal.next + return current + } + + /** Resumes the call at [site] that was in progress when the story was saved. */ + suspend fun resumeCall(site: Int): Any? { + resuming = false + val next = run.pending.firstOrNull() + ?: throw StoryException("The saved story lost the stage '$stage' was waiting for at '${sites[site]}'") + run.enterTrampolined = true + return completeCall(site, StoryInstances.invoke(next.instance, next.stage, next.args)) + } + + /** Resumes after the call at [site]; returns its saved result, or null when [serializer] is null. */ + fun resumeAfter(site: Int, serializer: KSerializer?): Any? { + resuming = false + if (serializer == null) return null + val saved = result ?: throw StoryException("The saved story lost the result of '${sites[site]}' in '$stage'") + return try { + StoryValues.decode(serializer, saved) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + throw StoryException("The result of '${sites[site]}' saved in '$stage' no longer reads as ${serializer.descriptor.serialName}", failure) + } + } + + /** + * The stage is about to call the action at [site]. The call is a boundary: once it is done a restart does not + * perform it again, and a restart in the middle of it finishes it with the record it wrote. + */ + fun beginAction(site: Int) { + run.discardPending("'$stage' moved on") + previous = CompoundTag().apply { + position?.let { putString("position", it) } + put("locals", locals.copy()) + result?.let { put("result", it) } + } + position = CALL + sites[site] + result = null + action = NO_RECORD + } + + /** The action at [site] returned [value], which the frame keeps with [serializer] for a restart after it. */ + fun completeAction(site: Int, value: Any?, serializer: KSerializer?): Any? { + position = AFTER + sites[site] + previous = null + action = null + result = serializer?.let { StoryValues.encode(it, value) } + segment = CompoundTag() + segmentMark = run.journal.next + return value + } + + /** Finishes the action at [site] that was in progress when the story was saved, from the record it wrote. */ + suspend fun resumeAction(site: Int): Any? { + resuming = false + val entry = action?.let(run.journal::entry) + ?: throw StoryException("The saved story lost the record of '${sites[site]}' in '$stage'") + return StoryActions.resume(entry) + } + + /** Whether the action starting now is the one called at the current position, which records it in [action]. */ + internal fun claimsAction(): Boolean = action == NO_RECORD + + /** The action called at the current position failed, and the stage goes on in the segment it was in before. */ + internal fun actionFailed() { + action = null + callFailed() + } + + /** Ends a segment at the checkpoint at [site], which the story can be rewound to. */ + fun checkpoint(site: Int, restart: Boolean) = pass(site, restart, emptyList()) + + /** Ends a segment at the checkpoint at [site] after [labels] captured what a rewind to it puts back. */ + suspend fun checkpoint(site: Int, restart: Boolean, labels: suspend CheckpointScope.() -> Unit) { + val scope = CheckpointScope(run.path) + scope.labels() + pass(site, restart, scope.labels) + } + + private fun pass(site: Int, restart: Boolean, labels: List) { + position = AFTER + sites[site] + passCheckpoint(sites[site], labels, restart) + } + + internal fun passCheckpoint(name: String, labels: List = emptyList(), restart: Boolean = false) { + run.discardPending("'$stage' moved on") + previous = null + result = null + segment = CompoundTag() + segmentMark = run.journal.next + run.journal.checkpointPassed(run.path, name) + run.history.add(name, run, labels, restart) + } + + internal fun standAt(site: String, calling: Boolean) { + position = (if (calling) CALL else AFTER) + site + } + + /** Whether the caller runs a tail call this stage leaves, so the stage may give its place away. */ + fun canTailCall(): Boolean = trampolined + + /** Gives this frame's place to [stage] of [receiver], which the caller starts with [args] once this stage returns. */ + fun tailCall(receiver: Any, stage: String, args: CompoundTag) { + close() + replaced = true + run.tail = StoryRun.Tail(StoryInstances.keyOf(receiver), stage, args) + } + + /** The stage returns [value]; the caller reads it back with [serializer] after a restart. */ + fun returning(value: Any?, serializer: KSerializer): Any? { + run.lastResult = StoryValues.encode(serializer, value) + return value + } + + /** The stage is leaving with [failure]. */ + fun abort(failure: Throwable) { + this.failure = failure + } + + /** The stage is leaving. Its frame stays when the whole story stopped, so it can be saved as it was. */ + fun leave() { + if (replaced) return + val reason = failure + failure = null + if (run.isCancelled || reason is StoryEntityMissing) return + close() + if (reason != null) run.frames.lastOrNull()?.callFailed() + } + + /** + * The stage this frame was calling failed, and the stage goes on, catching it or not. Until its next boundary + * it is back in the segment it was in before the call, which a restart then repeats. + */ + private fun callFailed() { + val before = previous ?: return + if (position?.startsWith(CALL) != true) return + position = before.getString("position").takeIf { before.contains("position") } + locals = before.getCompound("locals") + result = before.get("result") + previous = null + } + + private fun startOver(reason: String) { + HollowEngine.LOGGER.warn("Stage '{}' of '{}' starts over: {}", stage, instance, reason) + resuming = false + target = -1 + position = null + previous = null + locals = CompoundTag() + result = null + segment = CompoundTag() + run.journal.undoTemporary(run.path, entryMark) + segmentMark = entryMark + run.discardPending(reason) + } + + internal fun adopt(hash: Int, sites: Array): StoryFrame { + this.sites = sites + if (action == NO_RECORD) { + action = null + callFailed() + } + val saved = position + target = saved?.let(::positionOf) ?: -1 + if (saved != null && target < 0) { + startOver("it no longer has '$saved'") + } else if (saved != null && hash != this.hash) { + HollowEngine.LOGGER.debug( + "Stage '{}' of '{}' changed since it was saved, resuming at '{}'", + stage, + instance, + saved + ) + } + this.hash = hash + resuming = target >= 0 + boxOrdinals.clear() + if (target < 0 || target % 2 == 1) run.journal.undoTemporary(run.path, if (target < 0) entryMark else segmentMark) + return this + } + + internal fun fresh(hash: Int, sites: Array): StoryFrame { + this.hash = hash + this.sites = sites + entryMark = run.journal.next + segmentMark = entryMark + return this + } + + /** Takes the frame off the stack together with the branches it started. */ + private fun close() { + check(run.frames.lastOrNull() === this) { + "Stages of story '${run.path}' ran side by side; start parallel branches with async(name) { }" + } + run.frames.removeAt(run.frames.lastIndex) + children.forEach { run.closeChild(it) } + children.clear() + run.root.boxes.release(boxes) + } + + /** The next ordinal of a declaration of the shared var [name], which tells loop iterations apart. */ + internal fun nextBoxOrdinal(name: String): Int = boxOrdinals.merge(name, 1, Int::plus)!! - 1 + + private fun positionOf(saved: String): Int { + val call = saved.startsWith(CALL) + val site = sites.indexOf(saved.removePrefix(if (call) CALL else AFTER)) + return if (site < 0) -1 else site * 2 + if (call) 0 else 1 + } + + internal fun save(): CompoundTag = CompoundTag().apply { + putString("instance", instance) + putString("stage", stage) + putInt("hash", hash) + position?.let { putString("position", it) } + put("args", args) + put("locals", locals) + result?.let { put("result", it) } + previous?.let { put("previous", it) } + action?.let { putLong("action", it) } + put("segment", segment) + putLong("entryMark", entryMark) + putLong("segmentMark", segmentMark) + if (boxes.isNotEmpty()) put("boxes", ListTag().apply { boxes.forEach { add(StringTag.valueOf(it)) } }) + put("children", ListTag().apply { children.forEach { add(StringTag.valueOf(it)) } }) + }.copy() + + internal companion object { + private const val CALL = "call:" + private const val AFTER = "after:" + const val NO_RECORD = -1L + private const val MAX_ARGUMENTS_IN_KEY = 48 + + fun load(run: StoryRun, tag: CompoundTag) = + StoryFrame(run, tag.getString("instance"), tag.getString("stage")).apply { + hash = tag.getInt("hash") + position = tag.getString("position").takeIf { tag.contains("position") } + args = tag.getCompound("args").copy() + locals = tag.getCompound("locals").copy() + result = tag.get("result")?.copy() + previous = tag.getCompound("previous").copy().takeIf { tag.contains("previous") } + action = tag.getLong("action").takeIf { tag.contains("action") } + segment = tag.getCompound("segment").copy() + entryMark = tag.getLong("entryMark") + segmentMark = tag.getLong("segmentMark") + tag.getList("boxes", Tag.TAG_STRING.toInt()).forEach { boxes += it.asString } + tag.getList("children", Tag.TAG_STRING.toInt()).forEach { children += it.asString } + } + } +} + +/** Where the code the compiler generates for stages reaches the running story. */ +object StoryFrames { + /** Enters [stage] of [receiver], resuming the saved frame when the story is being restored. */ + suspend fun enter(receiver: Any, stage: String, hash: Int, sites: Array): StoryFrame { + val run = currentCoroutineContext()[StoryRun.Element]?.run + ?: throw StoryException("Stage '$stage' was called outside a story; start one with launchStory") + return run.enter(receiver, stage, hash, sites) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt new file mode 100644 index 000000000..828ab5c56 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHandleSerializer.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** A handle is written as the path of its run, and read back as the run under that path in the current story. */ +internal class StoryHandleSerializer(private val result: KSerializer?) : KSerializer> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.handle", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: StoryHandle) = encoder.encodeString(value.run.path) + + override fun deserialize(decoder: Decoder): StoryHandle { + val path = decoder.decodeString() + val current = StoryRun.current() ?: throw StoryException("A story handle is only read while a story runs") + val run = current.runs.find(path) ?: StoryRun.ended(current.runs, path.substringAfterLast('/')) + return StoryHandle(run, result) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt new file mode 100644 index 000000000..f4a4127a9 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryHistory.kt @@ -0,0 +1,129 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag + +/** + * The checkpoints a story passed, oldest first. + * + * A checkpoint keeps the whole tree of runs as it was there and marks the journal, so a rewind to it undoes what was + * performed since and puts the runs back. + */ +internal class StoryHistory(private val root: StoryRun) { + class Checkpoint( + val name: String, + val run: String, + val mark: Long, + val tree: CompoundTag, + val labels: List, + val restart: Boolean = false, + ) { + fun save(): CompoundTag = CompoundTag().apply { + putString("name", name) + putString("run", run) + putLong("mark", mark) + put("tree", tree.copy()) + if (restart) putBoolean("restart", true) + if (labels.isNotEmpty()) put("labels", ListTag().apply { labels.forEach { add(it.save()) } }) + } + + companion object { + fun load(tag: CompoundTag) = Checkpoint( + tag.getString("name"), + tag.getString("run"), + tag.getLong("mark"), + tag.getCompound("tree").copy(), + tag.getList("labels", Tag.TAG_COMPOUND.toInt()).map { JournalEntry.load(it as CompoundTag) }, + tag.getBoolean("restart"), + ) + } + } + + private val checkpoints = ArrayList() + + val all: List get() = checkpoints + + fun has(name: String): Boolean = checkpoints.any { it.name == name } + + /** The checkpoint a restart takes the story back to: the last one passed, if it asks for that. */ + val restartPoint: String? get() = checkpoints.lastOrNull()?.takeIf { it.restart }?.name + + /** Whether [run] passed a checkpoint of its own that is still kept: its entry at least, unless it was forgotten. */ + fun hasOwn(run: StoryRun): Boolean = checkpoints.any { it.run == run.path } + + /** [run] passed the checkpoint [name]: keeps the tree as it is now with what [labels] captured. */ + fun add(name: String, run: StoryRun, labels: List, restart: Boolean = false) { + checkpoints += Checkpoint(name, run.path, root.journal.next, root.saveTree(), labels, restart) + if (checkpoints.size > MAX_CHECKPOINTS) checkpoints.removeAt(0) + } + + /** The lowest mark a rewind can still go back to: the oldest checkpoint, or the segment a stage is in. */ + fun floor(segments: Sequence): Long = + (checkpoints.asSequence().map { it.mark } + segments).minOrNull() ?: root.journal.next + + /** Goes back to the latest checkpoint named [name]: the whole tree as it was there, labels put back. */ + suspend fun rewind(name: String) { + val index = checkpoints.indexOfLast { it.name == name } + if (index < 0) throw StoryException("Story '${root.path}' has no checkpoint '$name'") + val target = checkpoints[index] + root.halt("it rewinds to '$name'") + root.journal.rewind(target.mark) + target.labels.asReversed().forEach(StoryActions::undo) + checkpoints.subList(index + 1, checkpoints.size).clear() + root.restoreTree(target.tree) + } + + /** + * Takes [run] back to the latest checkpoint it passed itself, its entry at the least: [run] and its branches + * as they were there, what they performed since undone. Whatever runs beside it goes on. + */ + suspend fun rewindOwn(run: StoryRun) { + val index = checkpoints.indexOfLast { it.run == run.path } + if (index < 0) throw StoryException("Story '${run.path}' has no checkpoint left to rewind to") + val target = checkpoints[index] + run.halt("it rewinds to '${target.name}'") + val inside = { path: String -> path == run.path || path.startsWith("${run.path}/") } + root.journal.rewind(target.mark) { inside(it.run) } + target.labels.asReversed().forEach(StoryActions::undo) + checkpoints.subList(index + 1, checkpoints.size).removeAll { inside(it.run) } + run.restoreTree(subtree(target.tree, run.path)) + } + + /** What [tree], saved for the root, holds for the run at [path]. */ + private fun subtree(tree: CompoundTag, path: String): CompoundTag = + path.split('/').drop(1).fold(tree) { tag, name -> tag.getCompound("children").getCompound(name) } + + fun save(): CompoundTag = CompoundTag().apply { + put("checkpoints", ListTag().apply { checkpoints.forEach { add(it.save()) } }) + } + + fun load(tag: CompoundTag) { + checkpoints.clear() + tag.getList("checkpoints", Tag.TAG_COMPOUND.toInt()) + .forEach { checkpoints += Checkpoint.load(it as CompoundTag) } + } + + private companion object { + /** Older checkpoints are forgotten, so a long story does not grow its save without end. */ + const val MAX_CHECKPOINTS = 64 + } +} + +/** + * What a checkpoint captures besides the story: labels such as [position] and [inventory] take the state of the world + * when the checkpoint is passed and put it back on every rewind to it. + */ +@StoryDsl +class CheckpointScope internal constructor(private val run: String) { + internal val labels = ArrayList() + + internal fun add(action: StoryAction, args: CompoundTag, snapshots: CompoundTag) { + labels += JournalEntry(-1, action.id, run, Lifetime.RUN, null, args, snapshots, null) + } + + internal companion object { + /** The receiver of a label the journal calls again to put back what it captured. */ + val REPLAY = CheckpointScope("") + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt new file mode 100644 index 000000000..ef9dc1aa8 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstances.kt @@ -0,0 +1,298 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryInstances.register +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.* +import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn +import kotlin.reflect.KFunction +import kotlin.reflect.full.declaredFunctions + +/** + * The script instances stages run in, under keys that survive a restart. + * + * A key is the source path of the script ([StageScript]); a node adds what it is attached to, since every + * entity runs its own instance of an attached node. A saved frame names its instance by key, so a story + * resumes once a script with that key is running again. + */ +object StoryInstances { + private class Entry(val instance: Any) { + val users = LinkedHashSet() + val lifetime = Job() + } + + private val byKey = HashMap() + private val keys = IdentityHashMap() + + /** Stages the addon declares itself, such as [scene]: always there, under keys that never change. */ + private val builtins = mapOf(SceneStage.KEY to SceneStage) + private val stages = object : ClassValue() { + override fun computeValue(type: Class<*>): Stages { + val methods = type.declaredMethods + return Stages( + methods.mapNotNull { method -> method.getAnnotation(StageEntry::class.java)?.let { it.name to method } } + .toMap(), + methods.mapNotNull { method -> method.getAnnotation(StageCall::class.java)?.let { it.name to method } } + .toMap(), + methods.mapNotNull { method -> + method.getAnnotation(StageResult::class.java)?.let { it.name to method } + }.toMap(), + ) + } + } + private val listeners = ArrayList<(String) -> Unit>() + + /** + * Makes [instance] and the scripts it imports available to stories until [scope] ends. The compiler marks + * every script class with [StageScript]; other instances are ignored. + */ + fun register(instance: Any, scope: CoroutineScope) { + checkStoryClasses(instance.javaClass) + val job = scope.coroutineContext.job + val added = LinkedHashMap() + register(instance, job, added) + job.invokeOnCompletion { added.forEach { (key, entry) -> remove(key, entry, job) } } + added.keys.filter(byKey::containsKey).forEach { key -> listeners.forEach { it(key) } } + } + + /** The key [instance] is addressed by; [register] makes it available after construction has finished. */ + fun keyOf(instance: Any): String = keys.getOrPut(instance) { keyFor(instance) } + + /** The running instance registered under [key], if any. */ + fun find(key: String): Any? = byKey[key]?.instance ?: builtins[key] + + /** A running instance of the script class [type]; a node attached to several entities has one for each. */ + internal fun instanceOf(type: Class<*>): Any? = byKey.values.firstOrNull { it.instance.javaClass == type }?.instance + + /** The job of the instance under [key], which ends when the instance stops; null when nothing bounds it. */ + internal fun jobOf(key: String): Job? = byKey[key]?.takeIf { it.users.isNotEmpty() }?.lifetime + + /** Calls [listener] with every key that becomes available. */ + internal fun onAvailable(listener: (String) -> Unit) { + listeners += listener + } + + /** + * Starts or resumes [stage] of the instance under [key] with the encoded [args] and returns what it + * returns. + */ + suspend fun invoke(key: String, stage: String, args: CompoundTag): Any? = + invoke(key, stage, { it.entries }, arrayOf(args)) + + /** Calls the stage lambda [stage] of the instance under [key] with its [captured] values and [parameters]. */ + suspend fun call(key: String, stage: String, captured: CompoundTag, parameters: Array): Any? = + invoke(key, stage, { it.calls }, arrayOf(captured, *parameters)) + + /** Restores type information generated with the lambda, without serializing serializer implementations. */ + @Suppress("UNCHECKED_CAST") + internal fun resultSerializer(key: String, stage: String): KSerializer { + val instance = find(key) ?: throw StoryException("No running script is registered as '$key'") + val factory = stagesOf(instance.javaClass).results[stage] + ?: throw StoryException("Script '$key' has no result serializer for stage '$stage'; recompile the script") + return factory.invoke(instance) as KSerializer + } + + /** The script an instance [key] belongs to, or null when the key does not name a script. */ + internal fun scriptOf(key: String): ScriptId? = + runCatching { ScriptRegistry.parse(key.substringBefore('@')) }.getOrNull() + + /** + * Whether the script of [key] no longer exists, so nothing will run under [key] again. A script of a namespace + * that is not installed right now is not gone: turning its addon on brings it back. + */ + internal fun isGone(key: String): Boolean { + if (key in builtins) return false + val id = scriptOf(key) ?: return false + return ScriptRegistry.source(id.namespace) != null && ScriptRegistry.artifacts(id) == null + } + + /** The keys of every running instance. */ + val registered: Set get() = byKey.keys + + /** The stages the instance under [key] declares, or none when nothing runs under it. */ + fun stagesOf(key: String): Set = find(key)?.let { stagesOf(it.javaClass).entries.keys }.orEmpty() + + /** + * The running script [written] names in a scene: its key, or a path that ends the key, `quests/village.kts` for + * `project:scripts/quests/village.kts`. A node attached to entities runs one instance each, so it cannot be named. + */ + internal fun scriptNamed(written: String): String { + byKey.keys.firstOrNull { it == written }?.let { return it } + val found = byKey.keys.filter { key -> + val path = key.substringBefore('@').replace('\\', '/') + val single = '@' !in key || key.endsWith("@server") + single && (path.substringAfter(':') == written || path.endsWith("/$written")) + } + return when (found.size) { + 1 -> found.single() + 0 -> throw StoryException("No running script is '$written'") + else -> throw StoryException("'$written' names several scripts: ${found.joinToString()}") + } + } + + /** The Kotlin function behind [stage] of the instance under [key], whose parameters its saved arguments are. */ + internal fun stageFunction(key: String, stage: String): KFunction<*> { + val instance = find(key) ?: throw StoryException("No running script is registered as '$key'") + val entry = stagesOf(instance.javaClass).entries[stage] ?: throw StoryException("Script '$key' has no stage '$stage'") + val name = entry.name.removePrefix(ENTRY_PREFIX) + return instance::class.declaredFunctions.firstOrNull { it.name == name && it.isSuspend } + ?: throw StoryException("Script '$key' has no function for stage '$stage'") + } + + /** Whether the instance under [key] declares [stage]. */ + fun hasStage(key: String, stage: String): Boolean { + val instance = find(key) ?: return false + return stage in stagesOf(instance.javaClass).entries + } + + private suspend fun invoke( + key: String, + stage: String, + pick: (Stages) -> Map, + arguments: Array, + ): Any? { + val instance = find(key) ?: throw StoryException("No running script is registered as '$key'") + val method = + pick(stagesOf(instance.javaClass))[stage] ?: throw StoryException("Script '$key' has no stage '$stage'") + return suspendCoroutineUninterceptedOrReturn { continuation -> + try { + method.invoke(instance, *arguments, continuation) + } catch (failure: InvocationTargetException) { + throw failure.targetException + } + } + } + + /** Forgets everything, for tests and server shutdown. */ + internal fun clear() { + val entries = byKey.values.toList() + byKey.clear() + keys.clear() + entries.forEach { it.lifetime.cancel() } + } + + private fun register(instance: Any, job: Job, added: MutableMap) { + if (!instance.javaClass.isAnnotationPresent(StageScript::class.java)) return + val key = keyFor(instance) + val entry = byKey[key]?.takeIf { it.instance === instance } ?: Entry(instance).also { byKey[key] = it } + if (!entry.users.add(job)) return + keys[instance] = key + added[key] = entry + StoryActions.register(instance.javaClass, namespaceOf(instance), job) + importsOf(instance).forEach { register(it, job, added) } + } + + private fun remove(key: String, entry: Entry, job: Job) { + entry.users.remove(job) + if (entry.users.isNotEmpty()) return + if (byKey[key] === entry) byKey.remove(key) + if (byKey[key]?.instance !== entry.instance) keys.remove(entry.instance) + entry.lifetime.cancel() + } + + /** + * The script id of [type] when its source path belongs to a known script, so a world moved to another directory + * still finds its scripts; the path itself otherwise, and null for a class that is not a script. + */ + internal fun scriptPathOf(type: Class<*>): String? { + val location = type.getAnnotation(StageScript::class.java)?.path ?: return null + return ScriptRegistry.idOfLocation(location)?.let(ScriptRegistry::display) ?: location + } + + private fun keyFor(instance: Any): String { + builtins.entries.firstOrNull { it.value === instance }?.let { return it.key } + val path = scriptPathOf(instance.javaClass) ?: return instance.javaClass.name + if (instance !is NodeScript) return path + return when (val host = instance.host) { + is NodeHost.Server -> "$path@server" + is NodeHost.OfEntity -> "$path@${host.entity.uuid}" + } + } + + /** + * Reports a script that was compiled with stages but sees another copy of this addon: its stages and actions + * carry annotations stories do not recognize, so they would silently not exist. + */ + private fun checkStoryClasses(type: Class<*>) { + if (type.isAnnotationPresent(StageScript::class.java)) return + val seen = runCatching { Class.forName(StageScript::class.java.name, false, type.classLoader) }.getOrNull() + val problem = when { + seen != null && seen !== StageScript::class.java -> "runs with a copy of the narrative addon of its own" + seen == null && type.declaredMethods.any { it.name.startsWith(ENTRY_PREFIX) } -> "declares stages but cannot see the narrative addon" + else -> return + } + HollowEngine.LOGGER.error( + "Script {} {}, so stories cannot see its stages and actions. " + "List 'hollowengine-narrative' in dependsOn of the project the script belongs to", + type.name, + problem, + ) + } + + /** The namespace the actions of a script are registered in: that of the script, or `script` when unknown. */ + private fun namespaceOf(instance: Any): String { + val location = instance.javaClass.getAnnotation(StageScript::class.java)?.path ?: return "script" + return ScriptRegistry.idOfLocation(location)?.namespace ?: "script" + } + + /** + * The [saved] variables the instance under [key] and the scripts it imports declare, by the name a story keeps + * them under. A script property `var trust by saved(0)` is kept in the field `trust$delegate`. + */ + internal fun savedVariablesOf(key: String): Map> { + val instance = find(key) ?: return emptyMap() + val found = LinkedHashMap>() + val visited = Collections.newSetFromMap(IdentityHashMap()) + + fun collect(script: Any) { + if (!visited.add(script)) return + val path = scriptPathOf(script.javaClass) ?: script.javaClass.name + script.javaClass.declaredFields.filter { it.name.endsWith(DELEGATE_SUFFIX) }.forEach { field -> + val delegate = + runCatching { field.trySetAccessible(); field.get(script) }.getOrNull() as? SavedVariable<*> + ?: return@forEach + found["$path/${field.name.removeSuffix(DELEGATE_SUFFIX)}"] = delegate + } + importsOf(script).forEach(::collect) + } + collect(instance) + return found + } + + /** The imported scripts an instance holds: they come in as constructor arguments kept in fields. */ + private fun importsOf(instance: Any): List = + instance.javaClass.declaredFields.filter { !Modifier.isStatic(it.modifiers) && !it.type.isPrimitive } + .filter { it.type.isAnnotationPresent(StageScript::class.java) }.mapNotNull { field -> + runCatching { field.trySetAccessible(); field.get(instance) }.onFailure { + HollowEngine.LOGGER.warn( + "Cannot read imported script '{}'", + field.name, + it + ) + }.getOrNull() + } + + private fun stagesOf(type: Class<*>): Stages = stages.get(type) + + /** How the compiler names the entries it generates for stages. */ + private const val ENTRY_PREFIX = "stage\$entry\$" + + private const val DELEGATE_SUFFIX = "\$delegate" + + /** The generated methods of a script class: entries that start or resume a stage, calls of stage lambdas. */ + private class Stages( + val entries: Map, + val calls: Map, + val results: Map, + ) +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt new file mode 100644 index 000000000..b0e653689 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryJournal.kt @@ -0,0 +1,156 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag + +/** + * One call of an action a story made: what to call again to undo or fast-forward it. + * + * @param seq the place of the call in the journal, which only grows; checkpoints and segments mark it. + * @param until the checkpoint that takes a temporary action back; null for the next one its run passes. + * @param outer the call this one was made from, whose fast-forward repeats it too. + */ +internal class JournalEntry( + val seq: Long, + val action: String, + val run: String, + val lifetime: Lifetime, + val until: String?, + val args: CompoundTag, + val snapshots: CompoundTag, + val outer: Long?, +) { + var done = false + + /** When a temporary effect was taken back, by its checkpoint or its run ending. Null while it stands. */ + var undoneAt: Long? = null + + val active: Boolean get() = undoneAt == null + + fun save(): CompoundTag = CompoundTag().apply { + putLong("seq", seq) + putString("action", action) + putString("run", run) + putString("lifetime", lifetime.name) + until?.let { putString("until", it) } + put("args", args.copy()) + put("snapshots", snapshots.copy()) + outer?.let { putLong("outer", it) } + putBoolean("done", done) + undoneAt?.let { putLong("undone", it) } + } + + companion object { + fun load(tag: CompoundTag) = JournalEntry( + tag.getLong("seq"), + tag.getString("action"), + tag.getString("run"), + runCatching { Lifetime.valueOf(tag.getString("lifetime")) }.getOrDefault(Lifetime.RUN), + tag.getString("until").takeIf { tag.contains("until") }, + tag.getCompound("args").copy(), + tag.getCompound("snapshots").copy(), + tag.getLong("outer").takeIf { tag.contains("outer") }, + ).apply { + done = tag.getBoolean("done") + undoneAt = tag.getLong("undone").takeIf { tag.contains("undone") } + } + } +} + +/** + * The actions of a story and its branches, in the order they were performed. An entry is written before its action + * runs, so a save made in the middle of it still knows how to undo it. + * + * Entries are kept as long as a rewind may need them: a checkpoint or segment a stage is in marks the journal, + * and going back to a mark undoes what was done after it and brings back what was taken back after it. + */ +internal class StoryJournal { + private val entries = ArrayList() + + /** The place the next entry gets: a mark taken now precedes everything performed from here on. */ + var next = 0L + private set + + val isEmpty: Boolean get() = entries.isEmpty() + + fun record( + action: String, + run: String, + lifetime: Lifetime, + until: String?, + args: CompoundTag, + snapshots: CompoundTag, + outer: Long?, + ) = JournalEntry(next++, action, run, lifetime, until, args, snapshots, outer).also { entries += it } + + /** + * Takes back the temporary actions [run] performed since [from], in reverse: the segment that performed them + * repeats after a restart, or the run ends. With [branches] the branches of [run] count too. + */ + fun undoTemporary(run: String, from: Long, branches: Boolean = false) = takeBack { + it.seq >= from && (it.run == run || branches && it.run.startsWith("$run/")) + } + + /** + * [run] passed the checkpoint [name]: takes back the temporary actions that last until it, those [run] performed + * without naming a checkpoint and those of any run that named this one. + */ + fun checkpointPassed(run: String, name: String) = takeBack { it.until == name || it.until == null && it.run == run } + + private fun takeBack(which: (JournalEntry) -> Boolean) { + entries.filter { it.active && it.lifetime == Lifetime.TEMPORARY && which(it) }.asReversed().forEach { entry -> + StoryActions.undo(entry) + entry.undoneAt = next++ + } + } + + /** + * Brings the world covered by [scope] back to how it was at [mark] and forgets what was performed after it: + * undoes the actions performed since, and fast-forwards the temporary ones taken back since, latest first. + */ + suspend fun rewind(mark: Long, scope: (JournalEntry) -> Boolean = { true }) { + val covered = entries.filter(scope) + val undo = covered.filter { it.active && it.seq >= mark }.map { it.seq to it } + val restore = + covered.filter { !it.active && it.seq < mark && it.undoneAt!! >= mark }.map { it.undoneAt!! to it } + val restored = restore.map { it.second.seq }.toSet() + (undo + restore).sortedByDescending { it.first }.forEach { (_, entry) -> + if (entry.active) StoryActions.undo(entry) else { + if (entry.outer !in restored) StoryActions.fastForward(entry) + entry.undoneAt = null + } + } + entries.removeAll { scope(it) && it.seq >= mark } + } + + /** Takes back every temporary action left, for a run that ends; permanent ones stay in the world. */ + fun close(run: String) = undoTemporary(run, 0, branches = true) + + /** + * Forgets what no rewind can reach any more: nothing marks the journal before [floor], so an action taken back + * before it stays so and a permanent one performed before it stays performed. + */ + fun prune(floor: Long) { + entries.removeAll { entry -> + val undoneAt = entry.undoneAt + if (undoneAt != null) undoneAt < floor else entry.lifetime == Lifetime.RUN && entry.seq < floor + } + } + + fun entry(seq: Long): JournalEntry? = entries.firstOrNull { it.seq == seq } + + /** What [run] performed, for the inspector and commands. */ + fun entriesOf(run: String): List = entries.filter { it.run == run } + + fun save(): CompoundTag = CompoundTag().apply { + putLong("next", next) + put("entries", ListTag().apply { entries.forEach { add(it.save()) } }) + } + + fun load(tag: CompoundTag) { + entries.clear() + tag.getList("entries", Tag.TAG_COMPOUND.toInt()).forEach { entries += JournalEntry.load(it as CompoundTag) } + next = maxOf(tag.getLong("next"), (entries.maxOfOrNull { it.seq } ?: -1) + 1) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt new file mode 100644 index 000000000..1ee2704a0 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLabels.kt @@ -0,0 +1,55 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.Serializable +import net.minecraft.core.registries.Registries +import net.minecraft.resources.ResourceKey +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorCategory + +/** Where [entity] is when the checkpoint is passed; every rewind to the checkpoint brings it back there. */ +@EditorCategory("hollowengine.gui.ide.story.category.story") +@Action("position") +suspend fun CheckpointScope.position(entity: Entity) = label(this, entity) { + val placement = capture { Placement.of(entity) } + undo { placement.applyTo(entity) } +} + +/** What [player] carries when the checkpoint is passed; every rewind to the checkpoint gives back exactly that. */ +@EditorCategory("hollowengine.gui.ide.story.category.story") +@Action("inventory") +suspend fun CheckpointScope.inventory(player: ServerPlayer) = label(this, player) { + val inventory = player.inventory + val items = capture { (0 until inventory.containerSize).map { inventory.getItem(it).copy() } } + undo { + items.forEachIndexed { slot, stack -> inventory.setItem(slot, stack.copy()) } + player.containerMenu.broadcastChanges() + } +} + +/** A place and a facing in a dimension. */ +@Serializable +internal class Placement( + val dimension: String, + val x: Double, + val y: Double, + val z: Double, + val yaw: Float, + val pitch: Float, +) { + fun applyTo(entity: Entity) { + val server = entity.server ?: return + val key = ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse(dimension)) + val level: ServerLevel = server.getLevel(key) ?: throw StoryException("Dimension '$dimension' does not exist") + entity.teleportTo(level, x, y, z, emptySet(), yaw, pitch) + entity.deltaMovement = entity.deltaMovement.scale(0.0) + } + + companion object { + fun of(entity: Entity) = Placement( + entity.level().dimension().location().toString(), entity.x, entity.y, entity.z, entity.yRot, entity.xRot, + ) + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt new file mode 100644 index 000000000..1ead24a87 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoops.kt @@ -0,0 +1,40 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +/** + * What the compiler turns a `for` into when the loop calls stages: an index it can save instead of an iterator it + * cannot. A range is counted as it is; anything else is first copied into a list, so a collection changed while + * the story waits does not shift the elements under the saved index. + */ +object StoryLoops { + /** How many values [progression] goes through. */ + @JvmStatic + fun count(progression: IntProgression): Int { + if (progression.isEmpty()) return 0 + val size = (progression.last.toLong() - progression.first) / progression.step + 1 + require(size <= Int.MAX_VALUE) { "A resumable loop cannot index $size elements; split the progression into smaller ranges" } + return size.toInt() + } + + /** The value at [index] of a progression starting at [first] with [step]. */ + @JvmStatic + fun element(first: Int, step: Int, index: Int): Int = first + index * step + + /** The elements a `for` over [source] goes through, in order. */ + @JvmStatic + fun snapshot(source: Any?): List = when (source) { + is Iterable<*> -> source.toList() + is Map<*, *> -> source.entries.toList() + is Array<*> -> source.toList() + is IntArray -> source.toList() + is LongArray -> source.toList() + is ShortArray -> source.toList() + is ByteArray -> source.toList() + is FloatArray -> source.toList() + is DoubleArray -> source.toList() + is BooleanArray -> source.toList() + is CharArray -> source.toList() + is CharSequence -> source.toList() + is Iterator<*> -> source.asSequence().toList() + else -> throw StoryException("A loop that calls stages cannot go over ${source?.javaClass?.name}") + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt new file mode 100644 index 000000000..657c29ffe --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryOwners.kt @@ -0,0 +1,168 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.job +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.player.Player +import net.minecraft.world.level.Level +import net.minecraft.world.level.storage.LevelResource +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope +import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext +import ru.hollowhorizon.hollowengine.common.data.DataKey +import ru.hollowhorizon.hollowengine.common.data.data +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.entity.EntityEvent +import ru.hollowhorizon.hollowengine.common.events.entity.EntityLoadedEvent +import ru.hollowhorizon.hollowengine.common.events.level.LevelEvent +import ru.hollowhorizon.hollowengine.common.events.server.ServerEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeHost +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript +import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeSection +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT +import java.nio.file.Path + +/** + * The owners stories run for, and how each keeps its runs: the server in a file of the world, an entity in its + * own data, a node in its save. + */ +object StoryOwners { + private val servers = HashMap() + private val entities = HashMap() + private val nodes = HashMap() + + private val ENTITY_STORIES = DataKey("hollowengine-narrative:stories", ForCompoundNBT, null) + + fun server(server: MinecraftServer): StoryRuns = servers.getOrPut(server) { StoryRuns(ServerOwner(server)) } + + fun entity(entity: Entity): StoryRuns { + check(!entity.level().isClientSide) { "Stories run on the server" } + return entities.getOrPut(entity) { StoryRuns(EntityOwner(entity)) } + } + + fun node(node: NodeScript): StoryRuns = nodes[node] ?: throw StoryException("Node '${node.path}' is not running") + + /** The runs of the node [path] the server runs itself, not one attached to an entity. */ + fun serverNode(path: String): StoryRuns? = nodes.entries.firstOrNull { (node, _) -> node.path == path && node.host is NodeHost.Server }?.value + + /** The paths of the nodes the server runs itself. */ + val serverNodes: List get() = nodes.keys.filter { it.host is NodeHost.Server }.map { it.path } + + /** Makes every script that starts reachable to stories, and gives nodes their runs and their entry stage. */ + @SubscribeEvent + fun onScriptStarted(event: ScriptStartedEvent) { + StoryInstances.register(event.script, event.scope) // ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ скрипт ΠΌΠΎΠΆΠ΅Ρ‚ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ State-Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ, поэтому ΠΈΡ… Π½ΡƒΠΆΠ½ΠΎ Π·Π°Ρ€Π΅Π³ΠΈΡΡ‚Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + val node = event.script as? NodeScript ?: return + val runs = StoryRuns(NodeOwner(node)) + nodes[node] = runs + event.scope.coroutineContext.job.invokeOnCompletion { nodes.remove(node) } + node.section(NODE_SECTION, object : NodeSection { + override fun save(): Tag? = runs.save().takeIf { !it.isEmpty } + + override fun load(tag: Tag) { + (tag as? CompoundTag)?.let(runs::load) + } + }) + val entry = event.entry ?: return + runCatching { + node(node).launch( + NODE_ENTRY_KEY, StoryInstances.keyOf(node), entry + ) + }.onFailure { HollowEngine.LOGGER.error("Cannot start node '{}' at stage '{}'", node.path, entry, it) } + } + + @SubscribeEvent + fun onScriptFailed(event: ScriptFailedEvent) = StoryWaits.failed(event.id) + + @SubscribeEvent + fun onLevelLoad(event: LevelEvent.Load) { + val server = event.level.server ?: return + if (event.level.dimension() != Level.OVERWORLD) return + StoryDeferredReplays.load(StoryStore(deferredFile(server)).read() ?: CompoundTag()) + StoryStore(serverFile(server)).read()?.let(server(server)::load) + } + + @SubscribeEvent + fun onLevelSave(event: LevelEvent.Save) { + val server = event.level.server ?: return + if (event.level.dimension() == Level.OVERWORLD) saveServer(server) + } + + @SubscribeEvent + fun onServerStopping(event: ServerEvent.Stoping) { + saveServer(event.server) + servers.remove(event.server) + StoryWaits.clear() + StoryDeferredReplays.clear() + } + + @SubscribeEvent + fun onEntityLoaded(event: EntityLoadedEvent) { + val entity = event.entity + if (entity.level().isClientSide) return + StoryWaits.entityLoaded(entity.uuid) + StoryDeferredReplays.entityLoaded(entity) + entities[entity]?.let { it.wake(); return } + entity.data[ENTITY_STORIES]?.let(entity(entity)::load) + } + + /** An entity keeps its runs in its own data, written as the entity is saved, so the save has them as they are. */ + @SubscribeEvent + fun onEntitySaving(event: EntityEvent.Saving) { + val entity = event.entity + val runs = entities[entity] ?: return + if (runs.keys.isEmpty()) entity.data.remove(ENTITY_STORIES) else entity.data[ENTITY_STORIES] = runs.save() + } + + /** + * A player is the same object after a dimension change and keeps its runs; any other entity leaving its level + * takes them along. Only a destroyed entity ends its stories, and never a player, which is removed when it + * respawns as well. + */ + @SubscribeEvent + fun onEntityRemoved(event: EntityEvent.Removed) { + val entity = event.entity + if (entity is Player && event.reason == Entity.RemovalReason.CHANGED_DIMENSION) return + val runs = entities.remove(entity) ?: return + if (event.reason.shouldDestroy() && entity !is Player) runs.keys.toList().forEach(runs::stop) + } + + private fun saveServer(server: MinecraftServer) { + if (StoryDeferredReplays.changed) StoryStore(deferredFile(server)).write(StoryDeferredReplays.save()) + val runs = servers[server] ?: return + StoryStore(serverFile(server)).write(runs.save()) + } + + private fun deferredFile(server: MinecraftServer): Path = + server.getWorldPath(LevelResource.ROOT).resolve("data").resolve("hollowengine-narrative-deferred.dat") + + private fun serverFile(server: MinecraftServer): Path = + server.getWorldPath(LevelResource.ROOT).resolve("data").resolve("hollowengine-narrative-stories.dat") + + private class ServerOwner(override val server: MinecraftServer) : StoryOwner { + override val scope: CoroutineScope get() = server.runtimeContext.scope + override val entity: Entity? get() = null + } + + private class EntityOwner(override val entity: Entity) : StoryOwner { + override val scope: CoroutineScope get() = entity.coroutineScope + override val server: MinecraftServer + get() = entity.server ?: throw StoryException("Entity ${entity.uuid} has no server") + } + + private class NodeOwner(private val node: NodeScript) : StoryOwner { + override val scope: CoroutineScope get() = node + override val server: MinecraftServer get() = node.server + override val entity: Entity? get() = (node.host as? NodeHost.OfEntity)?.entity + } + + private const val NODE_SECTION = "hollowengine-narrative:stories" + + /** The run a node started with an entry stage, by `/hollowengine scripting run` or the editor, is kept under. */ + const val NODE_ENTRY_KEY = "main" +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt new file mode 100644 index 000000000..05c77c67a --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRun.kt @@ -0,0 +1,386 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.StringTag +import net.minecraft.nbt.Tag +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryRun.Companion.current +import kotlin.coroutines.CoroutineContext + +/** + * A running story: the stack of stage frames plus the branches it started with [async]. + * + * A run belongs to an owner ([StoryRuns]) under a key. It is saved whole: frames, branches, variables. + * A run that cannot go on right now (its owner or a script it runs in is gone) sleeps with that state kept and starts again from it. + */ +class StoryRun internal constructor( + internal val runs: StoryRuns, + val name: String, + internal val parent: StoryRun?, +) { + enum class Status(val done: Boolean) { SLEEPING(false), RUNNING(false), COMPLETED(true), FAILED(true), STOPPED(true) } + + internal class Tail(val instance: String, val stage: String, val args: CompoundTag) + + internal val frames = ArrayList() + internal val pending = ArrayDeque() + internal val children = LinkedHashMap() + private val ownVars = CompoundTag() + private val ownBoxes: StoryBoxTable? = if (parent == null) StoryBoxTable() else null + private val ownRecords = CompoundTag() + private val ownJournal = StoryJournal() + private val ownHistory: StoryHistory? = if (parent == null) StoryHistory(this) else null + private var moving = false + private val status = MutableStateFlow(Status.SLEEPING) + private var job: Job? = null + private val watched = HashMap() + internal var tail: Tail? = null + internal var lastResult: Tag? = null + internal var enterTrampolined = false + internal var result: Tag? = null + internal var failure: String? = null + private var stopping = false + + /** Where the run is kept: its key under the owner, or `parent/name` for a branch. */ + val path: String get() = parent?.let { "${it.path}/$name" } ?: name + + val context: StoryContext get() = runs.owner.context(path) + + /** Variables declared with [saved], shared by the whole tree of runs. */ + internal val vars: CompoundTag get() = parent?.vars ?: ownVars + + /** The vars stages share with their `@State` lambdas, saved with the tree like locals. */ + internal val boxes: StoryBoxTable get() = parent?.boxes ?: ownBoxes!! + + /** What [once] and [remember] keep: shared by the tree, and left as they are by a rewind. */ + internal val records: CompoundTag get() = parent?.records ?: ownRecords + + /** The actions the whole tree of runs performed. */ + internal val journal: StoryJournal get() = parent?.journal ?: ownJournal + + internal val history: StoryHistory get() = parent?.history ?: ownHistory!! + + val state: Status get() = status.value + + internal val isCancelled: Boolean get() = job?.isCancelled == true + + internal val isStopped: Boolean get() = stopping || state == Status.STOPPED + + /** Starts or wakes the run; does nothing while it runs or once it ended. */ + internal fun start() { + if (state.done || job?.isActive == true || !isAttached || lineage.any { it.moving }) return + val previous = job + if (previous != null && !previous.isCompleted) { + previous.invokeOnCompletion { start() } + return + } + val missing = (frames + pending).map { it.instance }.firstOrNull { StoryInstances.find(it) == null } + if (missing != null) { + runs.waitFor(missing, this) + return + } + if (parent != null && parent.state != Status.RUNNING) return + stopping = false + pending.addAll(0, frames) + frames.clear() + status.value = Status.RUNNING + val parentJob = parent?.job ?: runs.owner.scope.coroutineContext.job + val running = runs.owner.scope.launch(Element(this) + parentJob, start = CoroutineStart.LAZY) { drive() } + job = running + running.invokeOnCompletion { + if (job === running && state == Status.RUNNING) { + if (stopping) finish(Status.STOPPED, null, null) else pause() + } + } + running.start() + children.values.forEach { it.start() } + } + + private suspend fun drive() { + try { + val root = pending.firstOrNull() ?: throw StoryException("Story '$path' has nothing to run") + enterTrampolined = true + StoryInstances.invoke(root.instance, root.stage, root.args) + while (true) { + val next = tail ?: break + tail = null + enterTrampolined = true + StoryInstances.invoke(next.instance, next.stage, next.args) + } + finish(Status.COMPLETED, lastResult, null) + } catch (missing: StoryEntityMissing) { + HollowEngine.LOGGER.debug("Story '{}' waits for entity {}", path, missing.uuid) + pause() + if (StoryValues.entities(missing.uuid, Entity::class) != null) job?.invokeOnCompletion { start() } + else runs.waitFor(missing.uuid, this) + } catch (cancelled: CancellationException) { + if (currentCoroutineContext().isActive) { + HollowEngine.LOGGER.error("Story '{}' failed", path, cancelled) + finish(Status.FAILED, null, cancelled.message ?: cancelled.javaClass.simpleName) + return + } + if (stopping) finish(Status.STOPPED, null, null) else pause() + throw cancelled + } catch (failure: Throwable) { + HollowEngine.LOGGER.error("Story '{}' failed", path, failure) + finish(Status.FAILED, null, failure.message ?: failure.javaClass.simpleName) + } finally { + unwatch() + } + } + + internal fun enter(receiver: Any, stage: String, hash: Int, sites: Array): StoryFrame { + val key = StoryInstances.keyOf(receiver) + val trampolined = enterTrampolined + enterTrampolined = false + val saved = pending.firstOrNull() + val frame = if (saved != null && saved.instance == key && saved.stage == stage) { + pending.removeFirst().adopt(hash, sites) + } else { + saved?.let { discardPending("'$stage' was entered where '${it.stage}' was saved") } + StoryFrame(this, key, stage).fresh(hash, sites) + } + if (frames.size >= MAX_DEPTH) { + throw StoryException("Story '$path' nests more than $MAX_DEPTH stage calls; end a stage with a tail call") + } + frame.trampolined = trampolined + frames += frame + watch(key) + return frame + } + + /** Drops saved frames nobody adopted: the code no longer reaches them. */ + internal fun discardPending(reason: String) { + if (pending.isEmpty()) return + HollowEngine.LOGGER.warn("Story '{}' drops its saved stages {}: {}", path, pending.map { it.stage }, reason) + pending.clear() + } + + /** The branch [name] that the frame on top of the stack starts, or the one it started before a restart. */ + internal suspend fun branch(name: String, lambda: StageLambda): StoryRun { + require('/' !in name && name.isNotBlank()) { "A branch name must be non-blank and contain no '/': '$name'" } + val frame = frames.lastOrNull() ?: throw StoryException("async(\"$name\") must be called from a stage") + children[name]?.let { previous -> + if (!previous.isStopped) { + if (name !in frame.children) throw StoryException( + "Branch '$path/$name' belongs to another active stage; use a distinct branch name" + ) + return previous + } + previous.job?.join() + frames.forEach { it.children.remove(name) } + } + val child = StoryRun(runs, name, this) + child.pending += lambda.entry(child) + children[name] = child + frame.children += name + history.add(name, child, emptyList()) + child.start() + return child + } + + internal fun closeChild(name: String) { + children.remove(name)?.stop() + } + + /** Removes the whole story for good: a script it runs in will not come back, so it would wait forever. */ + internal fun discard(reason: String) { + val root = root + HollowEngine.LOGGER.error("Story '{}' is removed: {}", root.path, reason) + if (!root.runs.stop(root.name)) root.stop() + } + + /** Stops the run for good, along with its branches, and takes back their temporary actions at once. */ + internal fun stop() { + if (state.done) { + stopping = true + return + } + stopping = true + journal.close(path) + val running = job + if (running?.isActive == true) running.cancel(CancellationException("Story '$path' was stopped")) + else finish(Status.STOPPED, null, null) + } + + /** Puts the run to sleep, keeping where it is; [start] wakes it. */ + internal fun sleep(reason: String) { + if (state != Status.RUNNING) return + HollowEngine.LOGGER.debug("Story '{}' sleeps: {}", path, reason) + job?.cancel(CancellationException("Story '$path' sleeps: $reason")) + } + + internal suspend fun awaitDone(): Status = status.first { it.done } + + /** This run and every branch under it. */ + internal fun tree(): Sequence = sequenceOf(this) + children.values.asSequence().flatMap { it.tree() } + + /** + * Takes the story back: with [name], the whole tree to the latest checkpoint so named; without it, this run and + * its branches to the latest checkpoint this run passed itself. The runs stop, the journal undoes what they + * performed since, and they start again from there. + */ + internal fun rewind(name: String?) { + if (name != null && !history.has(name)) throw StoryException("Story '${root.path}' has no checkpoint '$name'") + if (name == null && !history.hasOwn(this)) throw StoryException("Story '$path' has no checkpoint left to rewind to") + if (name == null) move(this) { history.rewindOwn(this) } else move(root) { history.rewind(name) } + } + + /** Stops the run with its branches and waits until they have; [start] brings it back. */ + internal suspend fun halt(reason: String) { + val running = job ?: return + sleep(reason) + running.join() + } + + /** Puts the whole tree back to what [saveTree] wrote, ready to [start]. */ + internal fun restoreTree(tag: CompoundTag) { + frames.clear() + pending.clear() + children.clear() + tail = null + readTree(tag) + } + + /** Runs [change] on the stopped story outside of it, then starts [restart] again. */ + private fun move(restart: StoryRun, change: suspend () -> Unit) { + val root = root + if (root.tree().any { it.moving }) throw StoryException("Story '${root.path}' is already being rewound") + restart.moving = true + runs.owner.scope.launch { + try { + change() + } catch (failure: Exception) { + if (failure is CancellationException) throw failure + HollowEngine.LOGGER.error("Story '{}' failed to rewind", root.path, failure) + } finally { + restart.moving = false + restart.start() + } + } + } + + private fun pause() { + status.value = Status.SLEEPING + pending.addAll(0, frames) + frames.clear() + tail = null + } + + private fun finish(status: Status, result: Tag?, failure: String?) { + this.result = result + this.failure = failure + frames.clear() + pending.clear() + journal.close(path) + this.status.value = status + if (parent == null) runs.ended(this) + } + + private fun watch(key: String) { + if (key in watched) return + val instanceJob = StoryInstances.jobOf(key) ?: return + watched[key] = instanceJob.invokeOnCompletion { + runs.waitFor(key, root) + root.sleep("script '$key' stopped") + } + } + + private fun unwatch() { + watched.values.forEach(DisposableHandle::dispose) + watched.clear() + } + + internal val root: StoryRun get() = parent?.root ?: this + + /** This run and the runs it is a branch of. */ + private val lineage: Sequence get() = generateSequence(this) { it.parent } + + /** + * Whether the run is still part of its story. A rewind replaces the branches it restores, and a run that ended + * leaves its owner, so a wait that wakes such a run afterwards does nothing. + */ + private val isAttached: Boolean get() = if (parent == null) runs.holds(this) else parent.children[name] === this && parent.isAttached + + /** Everything to save; the root adds what the tree shares over time: records, journal, checkpoints. */ + internal fun save(): CompoundTag = saveTree().apply { + if (parent != null) return@apply + journal.prune(history.floor(tree().flatMap { (it.frames + it.pending).asSequence() }.map { it.segmentMark })) + if (!ownRecords.isEmpty) put("records", ownRecords.copy()) + put("journal", journal.save()) + put("history", history.save()) + } + + /** The runs as they are: stacks, branches, variables. A checkpoint keeps this. */ + internal fun saveTree(): CompoundTag = CompoundTag().apply { + putString("status", (if (state == Status.RUNNING) Status.SLEEPING else state).name) + if (stopping) putBoolean("stopping", true) + put("frames", ListTag().apply { (frames + pending).forEach { add(it.save()) } }) + if (children.isNotEmpty()) { + put("children", CompoundTag().apply { children.forEach { (name, run) -> put(name, run.saveTree()) } }) + put("order", ListTag().apply { children.keys.forEach { add(StringTag.valueOf(it)) } }) + } + if (parent == null && !ownVars.isEmpty) put("vars", ownVars.copy()) + if (parent == null) put("boxes", ownBoxes!!.save()) + result?.let { put("result", it.copy()) } + failure?.let { putString("failure", it) } + } + + private fun readTree(tag: CompoundTag) { + status.value = runCatching { Status.valueOf(tag.getString("status")) }.getOrDefault(Status.SLEEPING) + stopping = tag.getBoolean("stopping") + tag.getList("frames", Tag.TAG_COMPOUND.toInt()).forEach { pending += StoryFrame.load(this, it as CompoundTag) } + val saved = tag.getCompound("children") + val order = tag.getList("order", Tag.TAG_STRING.toInt()).map { it.asString }.filter(saved::contains) + (order + saved.allKeys.filter { it !in order }.sorted()).forEach { children[it] = load(runs, it, this, saved.getCompound(it)) } + if (parent == null) { + ownVars.allKeys.toList().forEach(ownVars::remove) + ownVars.merge(tag.getCompound("vars").copy()) + ownBoxes!!.load(tag.getCompound("boxes")) + } + result = tag.get("result")?.copy() + failure = tag.getString("failure").takeIf { tag.contains("failure") } + } + + /** What the run is doing, for logs and the inspector. */ + fun describe(): String = (frames + pending).joinToString(" > ") { frame -> + frame.position?.let { "${frame.stage}@$it" } ?: frame.stage + }.ifEmpty { state.name.lowercase() } + + /** Makes the run reachable from the stages it runs, and from [current] in code that is not suspending. */ + internal class Element(val run: StoryRun) : ThreadContextElement { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key<*> get() = Key + + override fun updateThreadContext(context: CoroutineContext): StoryRun? = CURRENT.get().also { CURRENT.set(run) } + + override fun restoreThreadContext(context: CoroutineContext, oldState: StoryRun?) = CURRENT.set(oldState) + } + + internal companion object { + const val MAX_DEPTH = 32 + private val CURRENT = ThreadLocal() + + /** The run whose code is executing on this thread. */ + fun current(): StoryRun? = CURRENT.get() + + /** A run a saved handle names that has ended and left its story: all a handle can still do is report so. */ + fun ended(runs: StoryRuns, name: String): StoryRun = StoryRun(runs, name, null).apply { status.value = Status.COMPLETED } + + fun load(runs: StoryRuns, name: String, parent: StoryRun?, tag: CompoundTag): StoryRun = + StoryRun(runs, name, parent).apply { + readTree(tag) + if (parent == null) { + ownRecords.merge(tag.getCompound("records")) + ownJournal.load(tag.getCompound("journal")) + ownHistory!!.load(tag.getCompound("history")) + } + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt new file mode 100644 index 000000000..b9ca19b35 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryRuns.kt @@ -0,0 +1,160 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +import java.util.UUID + +/** + * Whoever a story runs for: the server, an entity or player, or a node. The owner keeps its runs and + * decides how long they live: a run of a player sleeps while the player is offline, a run of a node while + * the node does not run. + */ +interface StoryOwner { + /** Where the runs of this owner execute; it ends when the owner goes away. */ + val scope: CoroutineScope + + val server: MinecraftServer + + /** The entity the stories are about, if the owner has one. */ + val entity: Entity? + + /** The context a stage of the run [path] sees. */ + fun context(path: String): StoryContext = StoryContext(this, path) +} + +/** What a stage knows about the run it is part of; see [context]. */ +class StoryContext internal constructor(val owner: StoryOwner, val run: String) { + val server: MinecraftServer get() = owner.server + + val entity: Entity get() = owner.entity ?: throw StoryException("Story '$run' does not belong to an entity") + + val player: ServerPlayer get() = entity as? ServerPlayer ?: throw StoryException("Story '$run' does not belong to a player") +} + +/** The runs of one [owner], by key. */ +class StoryRuns(val owner: StoryOwner) { + private val runs = LinkedHashMap() + + /** + * Starts [block] as the run [key] and returns its handle; a run already going under [key] is returned as it + * is. [block] must be a lambda written at the call, which the compiler turns into a stage. + */ + fun launch(key: String, block: @State suspend () -> Unit): StoryHandle { + val lambda = block as? StageLambda + ?: throw StoryException("launchStory(\"$key\") takes a lambda written in place, not a function value") + return start(key) { run -> lambda.entry(run) } + } + + /** Starts [stage] of the running script [instance] as the run [key], replacing a run already under [key]. */ + fun launch(key: String, instance: String, stage: String): StoryHandle { + if (!StoryInstances.hasStage(instance, stage)) throw StoryException("Script '$instance' has no stage '$stage'") + stop(key) + return start(key) { run -> StoryFrame(run, instance, stage) } + } + + /** Starts the `.story` [address] played to [with] as the run [key], replacing a run already under [key]. */ + fun launchScene(key: String, address: String, with: List): StoryHandle { + stop(key) + return start(key) { run -> SceneStage.lambda(address, with).entry(run) } + } + + /** Stops the run [key]; returns whether there was one. */ + fun stop(key: String): Boolean { + val run = runs.remove(key) ?: return false + run.stop() + return true + } + + operator fun get(key: String): StoryHandle? = runs[key]?.let { StoryHandle(it, null) } + + val keys: Set get() = runs.keys + + /** Everything to save. */ + fun save(): CompoundTag = CompoundTag().apply { runs.forEach { (key, run) -> put(key, run.save()) } } + + /** + * Restores what [save] wrote and starts it. A story that runs in a script which no longer exists is removed: + * nothing would ever wake it. + */ + fun load(tag: CompoundTag) { + tag.allKeys.filter { it !in runs }.forEach { key -> + val run = StoryRun.load(this, key, null, tag.getCompound(key)) + if (run.state.done) return@forEach + runs[key] = run + val gone = run.tree().flatMap { (it.frames + it.pending).asSequence() }.map { it.instance }.firstOrNull(StoryInstances::isGone) + when { + gone != null -> run.discard("its script '$gone' no longer exists") + run.history.restartPoint != null -> run.rewind(run.history.restartPoint) + else -> run.start() + } + } + } + + /** Wakes every sleeping run. */ + fun wake() = runs.values.toList().forEach(StoryRun::start) + + internal fun holds(run: StoryRun): Boolean = runs[run.name] === run + + /** The run at [path]: a story under its key, then its branches by name. */ + internal fun find(path: String): StoryRun? { + val names = path.split('/') + return names.drop(1).fold(runs[names.first()] ?: return null) { run, name -> run.children[name] ?: return null } + } + + internal fun ended(run: StoryRun) { + if (runs[run.name] === run) runs.remove(run.name) + } + + internal fun waitFor(instance: String, run: StoryRun) = StoryWaits.instance(instance, run) + + internal fun waitFor(entity: UUID, run: StoryRun) = StoryWaits.entity(entity, run) + + private fun start(key: String, entry: (StoryRun) -> StoryFrame): StoryHandle { + require('/' !in key && key.isNotBlank()) { "A story key must be non-blank and contain no '/': '$key'" } + runs[key]?.let { return StoryHandle(it, null) } + val run = StoryRun(this, key, null) + run.pending += entry(run) + run.history.add(key, run, emptyList()) + runs[key] = run + run.start() + return StoryHandle(run, null) + } +} + +/** Runs that sleep until a script or an entity they need is back. */ +internal object StoryWaits { + private val instances = HashMap>() + private val entities = HashMap>() + + init { + StoryInstances.onAvailable { key -> instances.remove(key)?.forEach(StoryRun::start) } + } + + fun instance(key: String, run: StoryRun) { + instances.getOrPut(key, ::LinkedHashSet) += run + } + + fun entity(uuid: UUID, run: StoryRun) { + entities.getOrPut(uuid, ::LinkedHashSet) += run + } + + /** The script [id] could not start: the stories waiting for an instance of it go. */ + fun failed(id: ScriptId) { + instances.keys.filter { StoryInstances.scriptOf(it) == id }.forEach { key -> + instances.remove(key)?.forEach { it.discard("its script '$key' could not start") } + } + } + + fun entityLoaded(uuid: UUID) { + entities.remove(uuid)?.forEach(StoryRun::start) + } + + fun clear() { + instances.clear() + entities.clear() + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryScenes.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryScenes.kt new file mode 100644 index 000000000..0eaaa2533 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryScenes.kt @@ -0,0 +1,79 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.withContext +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.addons.narrative.scene.commands.ScreenEffects +import ru.hollowhorizon.hollowengine.addons.narrative.speech.StoryAudience +import ru.hollowhorizon.hollowengine.addons.narrative.story.SceneStage.KEY + +/** + * Plays the story file [address] as a stage of the running story and returns when it ends. After a restart it goes on + * from the step it was at. + * + * @param address `path/name.story`, or `path/name.story#stage` for another start than `main`. + * @param with the players who see it and vote in its choices; the player a story of a player belongs to when null. + * Empty plays it to nobody. + */ +@State +suspend fun scene(address: String, with: List? = null) = SceneStage.play(address, with) + +/** The stage behind [scene]. Stories address it by [KEY], which saves keep. */ +internal object SceneStage { + const val KEY = "hollowengine-narrative:scene" + private const val STAGE = "scene" + private const val CALL = "call" + + private val ADDRESS = storySerializer() + private val PLAYERS = storySerializer>() + + @Suppress("UNCHECKED_CAST") + @StageEntry(STAGE) + suspend fun entry(args: CompoundTag): Any { + play(StoryValues.get(args, "address", ADDRESS) as String, StoryValues.get(args, "with", PLAYERS) as List) + return Unit + } + + /** The file [address] played to [with], as a stage a run can start with. */ + fun lambda(address: String, with: List): StageLambda0 { + val args = CompoundTag() + StoryValues.put(args, "address", ADDRESS, address) + StoryValues.put(args, "with", PLAYERS, with) + return StageLambda0(KEY, STAGE, args) + } + + suspend fun play(address: String, with: List?) { + val frame = StoryFrames.enter(this, STAGE, 0, arrayOf(CALL)) + try { + // Saved as resolved: NBT reads an empty nullable list back as null. + val players = with ?: listOf(playerOf(frame.run, address)) + frame.saveArg("address", ADDRESS, address) + frame.saveArg("with", PLAYERS, players) + val audience = StoryAudience(players) + try { + withContext(audience) { perform(frame, address) } + } finally { + ScreenEffects.giveBack(audience) + } + } catch (failure: Throwable) { + frame.abort(failure) + throw failure + } finally { + frame.leave() + } + } + + /** Calls the stage as a stage calls another: after a restart the saved one goes on instead of a new one. */ + private suspend fun perform(frame: StoryFrame, address: String) { + if (frame.isResumingAt(0) && frame.run.pending.isNotEmpty()) { + frame.resumeCall(0) + return + } + val (key, stage) = StoryScripts.resolve(address) + frame.beginCall(0) + frame.completeCall(0, StoryInstances.invoke(key, stage, CompoundTag())) + } + + private fun playerOf(run: StoryRun, address: String): ServerPlayer = run.runs.owner.entity as? ServerPlayer + ?: throw StoryException("scene(\"$address\") in story '${run.path}' needs with = players: the story does not belong to a player") +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryScripts.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryScripts.kt new file mode 100644 index 000000000..81f274def --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryScripts.kt @@ -0,0 +1,110 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryCallKind +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryFunctionSignature +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryFunctions +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraph +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphCompiler +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryNode +import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptTranslator +import java.io.File + +/** + * The `.story` files as running scripts: each is compiled and kept running while its text stays the same, so that + * [saved] variables and the stories in it live on through a reload that did not touch it. + */ +internal object StoryScripts : ScriptTranslator { + const val EXTENSION = ".story" + private const val MAIN = "main" + private const val NARRATIVE = "hollowengine-narrative" + + /** A running file: [revision] says what it means, so moving its nodes around does not restart it. */ + private class Loaded(val revision: String, val scope: CoroutineScope) + + private val loaded = HashMap() + + override fun translate(id: ScriptId, source: File): String { + val story = StoryGraphCompiler.compile(StoryGraph.decode(source.readText()), StoryActionFunctions) + val errors = story.problems.filter { !it.warning } + if (errors.isEmpty()) return story.code + val problems = errors.joinToString("; ") { problem -> + problem.node?.let { "$it: ${problem.message}" } ?: problem.message + } + throw StoryException("Story '${ScriptRegistry.display(id)}' cannot be built: $problems") + } + + /** Brings the running stories in line with the files. */ + fun reload() { + val files = ScriptRegistry.list(EXTENSION).toSet() + (loaded.keys - files).forEach { loaded.remove(it)?.scope?.cancel() } + files.forEach { id -> + runCatching { ensure(id) }.onFailure { HollowEngine.LOGGER.error(it.message, it) } + } + } + + /** The instance key and the stage [address] names: `path/name.story`, or `path/name.story#stage`. */ + fun resolve(address: String): Pair { + val file = address.substringBefore('#') + return ensure(find(file)) to address.substringAfter('#', MAIN) + } + + /** Stops everything, as the server that ran it ends. */ + fun stop() { + loaded.values.forEach { it.scope.cancel() } + loaded.clear() + } + + private fun find(file: String): ScriptId { + val exact = ScriptRegistry.parse(file) + if (ScriptRegistry.artifacts(exact) != null) return exact + val found = ScriptRegistry.list(EXTENSION).filter { it.path == file || it.path.endsWith("/$file") } + return found.singleOrNull() ?: throw StoryException( + if (found.isEmpty()) "There is no story '$file'" else "'$file' names several stories: ${found.joinToString()}", + ) + } + + /** Runs the file of [id] unless it runs already as it is now, and gives its key. */ + private fun ensure(id: ScriptId): String { + val key = ScriptRegistry.display(id) + val text = ScriptRegistry.artifacts(id)?.sourceFile?.readText() + val revision = text?.let { written -> runCatching { StoryGraph.decode(written).revision() }.getOrDefault(written) } + val current = loaded[id] + if (current != null && (revision == null || current.revision == revision)) return key + val instance = ScriptLoader.execute(id).getOrElse { throw failure(id, it) } + val scope = CoroutineScope(SupervisorJob()) + StoryInstances.register(instance, scope) + loaded.put(id, Loaded(revision.orEmpty(), scope))?.scope?.cancel() + return key + } + + private fun failure(id: ScriptId, cause: Throwable): StoryException { + val hint = + if (NARRATIVE in ScriptRegistry.source(id.namespace)?.dependencies.orEmpty()) "" else " List '$NARRATIVE' in dependsOn of the project it belongs to." + return StoryException("Story '${ScriptRegistry.display(id)}' failed to load: ${cause.message}$hint", cause) + } + +} + +/** The commands of the running game. */ +internal object StoryActionFunctions : StoryFunctions { + private fun command(call: StoryNode.Call) = call.action?.let(ActionCommands::find)?.singleOrNull() + + override fun signature(call: StoryNode.Call): StoryFunctionSignature? = + StoryFunctions.Builtin.signature(call) ?: command(call)?.signature + + override fun kind(call: StoryNode.Call): StoryCallKind { + val action = command(call)?.action ?: return StoryCallKind.SUSPENDING + return when { + action.reversible -> StoryCallKind.ACTION + !action.function.isSuspend -> StoryCallKind.INSTANT + else -> StoryCallKind.SUSPENDING + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt new file mode 100644 index 000000000..9cfa8821d --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StorySegments.kt @@ -0,0 +1,125 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.delay +import kotlinx.serialization.KSerializer +import net.minecraft.nbt.CompoundTag +import ru.hollowhorizon.hollowengine.common.utils.currentServer +import java.lang.Math.addExact +import java.time.Instant +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * The data of the segment the current stage is in: since the last stage call or checkpoint. It is saved with + * the story and starts empty at the next boundary, so it remembers what a segment already did when the segment + * repeats after a restart. + */ +suspend fun segmentTag(): CompoundTag = currentFrame("segmentTag()").segment + +/** + * Runs [block] once for the whole story: the segment repeated after a restart or a [rewind] skips it, so a reward is + * not given twice. [name] belongs to the segment it is called in, of a stage called with these arguments; inside a + * loop, put the index into it. + */ +suspend fun once(name: String, block: suspend () -> Unit) { + val frame = currentFrame("once(\"$name\")") + val done = frame.run.records.getCompound(ONCE) + val key = frame.recordKey(name) + if (done.contains(key)) return + block() + done.putBoolean(key, true) + frame.run.records.put(ONCE, done) +} + +/** Computes a value once for the whole story, like [once]: a repeated segment gets the value computed the first time. */ +suspend inline fun remember(name: String, noinline block: suspend () -> T): T = + remember(name, storySerializer(), block) + +@PublishedApi +@Suppress("UNCHECKED_CAST") +internal suspend fun remember(name: String, serializer: KSerializer, block: suspend () -> T): T { + val frame = currentFrame("remember(\"$name\")") + val values = frame.run.records.getCompound(REMEMBERED) + val key = frame.recordKey(name) + values.get(key)?.let { return StoryValues.decode(serializer, it) as T } + val value = block() + StoryValues.put(values, key, serializer, value) + frame.run.records.put(REMEMBERED, values) + return value +} + +/** Forgets what [remember] or [once] kept under [name] in this segment; returns whether there was something. */ +suspend fun forget(name: String): Boolean { + val frame = currentFrame("forget(\"$name\")") + val key = frame.recordKey(name) + val records = frame.run.records + val existed = listOf(ONCE, REMEMBERED).count { kind -> + val tag = records.getCompound(kind) + tag.contains(key).also { if (it) tag.remove(key) } + } > 0 + return existed +} + +/** Waits for loaded server ticks; a segment repeated after a restart continues the same countdown. */ +suspend fun wait(ticks: Int, name: String) { + val frame = currentFrame("wait(ticks, \"$name\")") + waitPersistent(frame.segment, name, ticks, { currentServer.overworld().gameTime }) { delay(TICK) } +} + +/** Waits for wall-clock time, counting the time the game was not running. */ +suspend fun wait(duration: Duration, name: String) { + require(duration.isFinite() && duration >= Duration.ZERO) { "Realtime wait duration must be finite and non-negative" } + wait(Instant.ofEpochMilli(addExact(System.currentTimeMillis(), duration.inWholeMilliseconds)), name) +} + +/** Waits until [instant]; the deadline set the first time is kept across restarts. */ +suspend fun wait(instant: Instant, name: String) { + val frame = currentFrame("wait(instant, \"$name\")") + waitUntil(frame.segment, name, instant.toEpochMilli(), System::currentTimeMillis) { delay(it.milliseconds) } +} + +internal suspend fun waitPersistent( + tag: CompoundTag, + name: String, + ticks: Int, + currentTick: () -> Long, + pause: suspend () -> Unit, +) { + require(ticks >= 0) { "Persistent wait duration cannot be negative" } + val key = waitKey(GAME_TIME, name) + val deadline = if (tag.contains(key)) tag.getLong(key) else addExact(currentTick(), ticks.toLong()).also { tag.putLong(key, it) } + while (currentTick() < deadline) pause() + tag.remove(key) +} + +internal suspend fun waitUntil( + tag: CompoundTag, + name: String, + requestedDeadline: Long, + nowMillis: () -> Long, + pause: suspend (Long) -> Unit, +) { + val key = waitKey(REAL_TIME, name) + val deadline = if (tag.contains(key)) tag.getLong(key) else requestedDeadline.also { tag.putLong(key, it) } + while (true) { + val remaining = deadline - nowMillis() + if (remaining <= 0L) break + pause(minOf(remaining, MAX_REALTIME_SLEEP_MILLIS)) + } + tag.remove(key) +} + +private suspend fun currentFrame(what: String): StoryFrame = + currentRun(what).frames.lastOrNull() ?: throw StoryException("$what must be called from a stage") + +private fun waitKey(kind: String, name: String): String { + require(name.isNotBlank()) { "Persistent wait name cannot be blank" } + return "wait$.$kind.$name" +} + +private const val ONCE = "once" +private const val REMEMBERED = "remembered" +private const val GAME_TIME = "game" +private const val REAL_TIME = "real" +private const val MAX_REALTIME_SLEEP_MILLIS = 60_000L +private val TICK = 50.milliseconds diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt new file mode 100644 index 000000000..2a65433ba --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStore.kt @@ -0,0 +1,51 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.Util +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.NbtIo +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.utils.nbt.save +import java.io.DataInputStream +import java.nio.file.Files +import java.nio.file.Path + +/** Writes a complete snapshot before replacing the last save, keeping a recoverable previous version. */ +internal class StoryStore(private val file: Path) { + private val backup = file.resolveSibling("${file.fileName}_old") + + fun read(): CompoundTag? { + if (!Files.exists(file) && !Files.exists(backup)) return null + return read(file) ?: read(backup) + } + + private fun read(path: Path): CompoundTag? { + if (!Files.exists(path)) return null + return try { + DataInputStream(Files.newInputStream(path)).use(NbtIo::read) + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Cannot read stories from {}", path, failure) + null + } + } + + fun write(tag: CompoundTag): Boolean { + var temporary: Path? = null + return try { + Files.createDirectories(file.parent) + temporary = Files.createTempFile(file.parent, "${file.fileName}-", ".tmp") + Files.newOutputStream(temporary).use { tag.save(it) } + Util.safeReplaceOrMoveFile(file, temporary, backup, false).also { + if (!it) HollowEngine.LOGGER.error("Cannot replace story save {}", file) + } + } catch (failure: Exception) { + HollowEngine.LOGGER.error("Cannot save stories to {}", file, failure) + false + } finally { + temporary?.let { path -> + runCatching { Files.deleteIfExists(path) }.onFailure { + HollowEngine.LOGGER.warn("Cannot remove temporary story save {}", path, it) + } + } + } + } +} diff --git a/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt new file mode 100644 index 000000000..f54926053 --- /dev/null +++ b/addons/narrative/src/main/java/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValues.kt @@ -0,0 +1,199 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.* +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer +import net.minecraft.core.registries.Registries +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.resources.ResourceKey +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryValues.put +import ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef +import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs +import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull +import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat +import java.util.* +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Turns the values a story keeps (stage arguments, results, locals, saved variables) into NBT and back. + * + * Everything [NBTFormat] can write is accepted. Entities, at any depth of a list, set, map, pair or triple, + * are written as references and found again when read. + */ +object StoryValues { + private val serializers = HashMap>() + + /** Finds the entity a reference names, or null when it is not around right now. */ + var entities: (UUID, KClass) -> Entity? = ::findOnServer + + private val offWorld = ThreadLocal.withInitial { false } + + /** + * Runs [block] with a reference to an NPC that has no body read as the NPC kept in the registry. An undo puts such + * an NPC back through the registry at once; a stage would act on a copy that never moves, so it waits for the body. + */ + internal fun withOffWorldNpcs(block: () -> T): T { + val before = offWorld.get() + offWorld.set(true) + try { + return block() + } finally { + offWorld.set(before) + } + } + + @Suppress("UNCHECKED_CAST") + fun serializer(type: KType): KSerializer = serializers.getOrPut(type) { + val classifier = + type.classifier as? KClass<*> ?: throw StoryException("A story cannot keep a value of type $type") + val arguments by lazy { + type.arguments.map { argument -> + argument.type?.let(::serializer) ?: throw StoryException("A story cannot keep a value of type $type") + } + } + val base: KSerializer<*> = when { + Entity::class.java.isAssignableFrom(classifier.java) -> EntityReference(classifier as KClass) + classifier == NpcRef::class -> NpcReference + classifier == ServerLevel::class -> LevelReference + classifier == Duration::class -> DurationMillis + classifier.isFunction() -> StageLambda.Serializer + classifier == StoryHandle::class -> StoryHandleSerializer(type.arguments.firstOrNull()?.type?.let(::serializer)) + classifier == List::class || classifier == Collection::class -> ListSerializer(arguments[0]) + classifier == Set::class -> SetSerializer(arguments[0]) + classifier == Map::class -> MapSerializer(arguments[0], arguments[1]) + classifier == Map.Entry::class -> EntrySerializer(arguments[0], arguments[1]) + classifier.java.isArray && !classifier.java.componentType.isPrimitive -> ArraySerializer(elementClass(type), arguments[0]) + classifier == StoryBox::class -> StoryBoxSerializer(arguments[0]) + classifier == Pair::class -> PairSerializer(arguments[0], arguments[1]) + classifier == Triple::class -> TripleSerializer(arguments[0], arguments[1], arguments[2]) + else -> runCatching { NBTFormat.serializersModule.serializer(type) }.getOrElse { + throw StoryException("A story cannot keep a value of type $type: it is not serializable", it) + } + } + (if (type.isMarkedNullable) (base as KSerializer).nullable else base) as KSerializer + } + + /** Writes [value]; a top-level null gets a marker of its own, which the NBT format has no reliable form for. */ + fun encode(serializer: KSerializer, value: Any?): Tag = + if (value == null) NULL.copy() else NBTFormat.serialize(serializer, value) + + fun decode(serializer: KSerializer, tag: Tag): Any? = + if (tag == NULL) null else NBTFormat.deserialize(serializer, tag) + + private val NULL = CompoundTag().apply { putBoolean("story\$null", true) } + + /** Writes [value] into [tag] under [name]. */ + @JvmStatic + fun put(tag: CompoundTag, name: String, serializer: KSerializer, value: Any?) { + tag.put(name, encode(serializer, value)) + } + + /** Reads what [put] wrote under [name]; a value that no longer reads as its type is reported by name. */ + @JvmStatic + fun get(tag: CompoundTag, name: String, serializer: KSerializer): Any? { + val value = tag.get(name) ?: throw StoryException("The saved story has no value '$name'") + return try { + decode(serializer, value) + } catch (missing: StoryEntityMissing) { + throw missing + } catch (failure: Exception) { + throw StoryException("The saved '$name' no longer reads as ${serializer.descriptor.serialName}", failure) + } + } + + @Suppress("UNCHECKED_CAST") + private fun elementClass(type: KType): KClass = type.arguments.singleOrNull()?.type?.classifier as? KClass + ?: throw StoryException("A story cannot keep a value of type $type") + + private fun KClass<*>.isFunction(): Boolean = StageLambda::class.java.isAssignableFrom(java) || qualifiedName.orEmpty().let { + it.startsWith("kotlin.Function") || it.startsWith("kotlin.coroutines.SuspendFunction") + } || Function::class.java.isAssignableFrom(java) + + /** A loaded entity, or an NPC without a body: the registry keeps those apart from chunks, so they are always there. */ + private fun findOnServer(uuid: UUID, type: KClass): Entity? { + val server = currentServerOrNull() ?: return null + return server.findEntity(uuid) ?: server.npcs.resolve(uuid)?.takeIf { offWorld.get() } + } + + private fun MinecraftServer.findEntity(uuid: UUID): Entity? = + playerList.getPlayer(uuid) ?: allLevels.firstNotNullOfOrNull { it.getEntity(uuid) } + + private class EntityReference(private val type: KClass) : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.entity", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Entity) = encoder.encodeString(value.uuid.toString()) + + override fun deserialize(decoder: Decoder): Entity { + val uuid = UUID.fromString(decoder.decodeString()) + val entity = entities(uuid, type) ?: throw StoryEntityMissing(uuid) + if (!type.isInstance(entity)) { + throw StoryException("Entity $uuid is a ${entity.javaClass.simpleName}, not a ${type.simpleName}") + } + return entity + } + } + + /** A map entry as the pair it is: the NBT format would take kotlinx's entry for a map and fail on its key. */ + private class EntrySerializer(key: KSerializer, value: KSerializer) : KSerializer> { + private val pair = PairSerializer(key, value) + + override val descriptor: SerialDescriptor = SerialDescriptor("story.entry", pair.descriptor) + + override fun serialize(encoder: Encoder, value: Map.Entry) = + encoder.encodeSerializableValue(pair, value.key to value.value) + + override fun deserialize(decoder: Decoder): Map.Entry = + decoder.decodeSerializableValue(pair).let { AbstractMap.SimpleImmutableEntry(it.first, it.second) } + } + + /** A duration as whole milliseconds, the unit stories count time in. */ + private object DurationMillis : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.duration", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Duration) = encoder.encodeLong(value.inWholeMilliseconds) + + override fun deserialize(decoder: Decoder): Duration = decoder.decodeLong().milliseconds + } + + /** A level by its dimension, which must be loaded to read it back. */ + private object LevelReference : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.level", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ServerLevel) = + encoder.encodeString(value.dimension().location().toString()) + + override fun deserialize(decoder: Decoder): ServerLevel { + val name = decoder.decodeString() + val key = ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse(name)) + return currentServerOrNull()?.getLevel(key) ?: throw StoryException("Dimension '$name' is not loaded") + } + } + + private object NpcReference : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("story.npc", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: NpcRef) = encoder.encodeString(value.uuid.toString()) + + override fun deserialize(decoder: Decoder): NpcRef = NpcRef(UUID.fromString(decoder.decodeString())) + } +} + +/** The serializer a story keeps a [T] with. The compiler calls it for every value a stage saves. */ +inline fun storySerializer(): KSerializer = StoryValues.serializer(typeOf()) + +/** A saved story names an entity that is not loaded, so the run waits until it is. */ +class StoryEntityMissing(val uuid: UUID) : RuntimeException("Entity $uuid is not loaded") diff --git a/addons/narrative/src/main/lang/en_us/hollowengine-narrative/story/command.json b/addons/narrative/src/main/lang/en_us/hollowengine-narrative/story/command.json new file mode 100644 index 000000000..edd67bb7e --- /dev/null +++ b/addons/narrative/src/main/lang/en_us/hollowengine-narrative/story/command.json @@ -0,0 +1,167 @@ +{ + "async": "In parallel", + "async.description": "Plays what is inside, or one command, on a track of its own while the story goes on.", + "async.track": "Track", + "async.track.description": "The name @await and @cancel know the track by.", + "avoid": "Avoid zone", + "avoid.description": "Keeps out of a zone when finding a path.", + "await": "Wait for tracks", + "await.description": "Waits until the tracks end; every one when none is named.", + "await.tracks": "Tracks", + "await.tracks.description": "Names separated by spaces.", + "call": "Play label", + "call-define": "Call function", + "call-define.args": "Arguments", + "call-define.description": "Plays a @define and, when it returns a value, puts it into a variable.", + "call-define.name": "Function", + "call-define.result": "Into variable", + "call.description": "Plays from a label until @return, then comes back.", + "call.target": "#Label or file.story#Label", + "camera": "Camera", + "camera.description": "Moves the player's camera to a point and turns it.", + "camera.fov": "Field of view", + "camera.location": "Location", + "camera.rotation": "Rotation", + "camera.rotation.description": "[rotation Y, rotation X] in degrees.", + "cancel": "Cancel task", + "cancel.description": "Stops a task started by a Parallel block.", + "cancel.track": "Track", + "change-sound": "Change sound", + "checkpoint": "Checkpoint", + "checkpoint.description": "A point the story can rewind to.", + "checkpoint.name": "Name", + "choose": "Choose", + "choose.description": "Offers the options and gives back the number of the one chosen, from 0.", + "choose.options": "Options", + "command": "Server command", + "command.description": "Runs a vanilla command; {variables} are put in.", + "command.text": "Command", + "cutscene": "Cutscene", + "cutscene.description": "Plays a cutscene and waits until it ends.", + "cutscene.name": "Cutscene", + "cutscene.time": "Length", + "cutscene.time.description": "How long to play it; the whole cutscene when left out.", + "define": "Function", + "define.description": "A block @call plays by name; reading top-down skips it.", + "define.name": "Name", + "define.params": "Parameters", + "define.params.description": "Names separated by commas.", + "despawn-npc": "Remove NPC", + "destroy-block": "Break block", + "destroy-block.description": "Breaks a block in reach.", + "fade-in": "Fade in", + "fade-in.description": "Covers the screen with a color.", + "fade-out": "Fade out", + "fade-out.description": "Takes the color off the screen.", + "find-npc": "Find NPC", + "hide-hud": "Hide HUD", + "hide-hud.description": "Hides the HUD, all of it or only some of its layers.", + "hide-hud.except": "Except", + "hide-hud.except.description": "The layers to keep.", + "hide-hud.only": "Only", + "hide-hud.only.description": "The layers to hide; every one when left out.", + "import": "Import", + "import.description": "Lets @call play the functions of another story.", + "import.story": "Story", + "interact": "Interact", + "interact.description": "Uses an entity, as a player clicking it with the right button.", + "inventory": "Save inventory", + "inventory.description": "A checkpoint keeps the player's inventory and puts it back on rewind.", + "join": "Wait for the task", + "jump": "Go to label", + "jump.description": "Goes on from a label, here or in another story, and does not come back.", + "jump.target": "#Label or file.story#Label", + "look-at": "Glance at point", + "look-at-entity": "Glance at entity", + "look-at-entity.description": "Turns to look at an entity and follows it with the eyes.", + "look-at-entity.target": "At whom", + "look-at.description": "Turns to look at a point.", + "move-through": "Pass through points", + "move-through.description": "Walks through the points in order.", + "move-to": "Reach point", + "move-to-entity": "Reach entity", + "move-to-entity.description": "Walks up to an entity, following it, and waits until it gets there.", + "move-to.description": "Walks to a point and waits until it gets there.", + "move-vfx": "Move effect", + "next-tick": "Next tick", + "npc": "Spawn NPC", + "npc.description": "Puts an NPC into the world and gives it back; taken away again when the story rewinds past it.", + "npc.id": "Type", + "npc.id.description": "The NPC to spawn, such as village:guard; a plain one when left out.", + "play-clamped": "Play and hold", + "play-clamped.description": "Plays an animation and holds its last frame.", + "play-looped": "Loop animation", + "play-looped.description": "Plays an animation over and over until it is stopped.", + "play-montage": "Play montage", + "play-once": "Play animation", + "play-once.description": "Plays an animation once and waits until it ends.", + "play-ping-pong": "Play back and forth", + "play-ping-pong.description": "Plays an animation forward, then backward, over and over.", + "play-sound": "Play sound", + "play-sound-named": "Play sound", + "play-sound.description": "Plays a sound to a player.", + "play-sound.name": "Sound", + "play-vfx": "Play effect", + "play-vfx-on": "Play effect on entity", + "play-video": "Play video", + "position": "Save position", + "position.description": "A checkpoint keeps where the entity stands and puts it back there on rewind.", + "release-camera": "Release camera", + "release-camera.description": "Gives the camera back to the player.", + "reset-material": "Reset material", + "reset-materials": "Reset all materials", + "return": "Return", + "return.description": "Goes back to where @call came from, with a value from a @define.", + "return.value": "Value", + "rewind": "Rewind", + "rewind.checkpoint": "Checkpoint", + "rewind.checkpoint.description": "The latest one passed when left out.", + "rewind.description": "Goes back to a checkpoint, taking back what was done since.", + "say": "Say", + "say.description": "Shows a line and waits until it is read.", + "set": "Set variable", + "set-material": "Set material", + "set.description": "Puts a value into a variable.", + "set.value": "Value", + "set.value.description": "An expression; a command such as npc([0, 64, 0]) gives its result.", + "set.variable": "Variable", + "show-hud": "Show HUD", + "show-hud.description": "Shows the HUD hidden before.", + "spawn-npc": "Spawn NPC", + "spawn-npc.description": "Puts an NPC in the world and gives it back; a rewind of the story removes it.", + "spawn-npc.id.description": "Which NPC to make, such as village:guard; without one it is always a new NPC.", + "spawn-npc.model.description": "The path to the model of the NPC.", + "spawn-npc.name.description": "The name over the head of the NPC.", + "stage": "Script stage", + "stage.args": "Arguments", + "stage.description": "Plays a @State function of a script and waits for it.", + "stage.target": "script.kts#stage", + "start-following": "Follow entity", + "start-looking": "Look at point", + "start-montage": "Start montage", + "start-moving": "Walk to point", + "start-watching": "Look at entity", + "stop-all-animations": "Stop all animations", + "stop-animation": "Stop animation", + "stop-animation.description": "Stops an animation, blending it out.", + "stop-looking": "Stop looking", + "stop-looking.description": "Stops looking at what it looked at.", + "stop-montage": "Stop montage", + "stop-moving": "Stop", + "stop-moving.description": "Stops walking.", + "stop-sound": "Stop sound", + "stop-vfx": "Stop effect", + "teleport": "Teleport", + "temporary": "Temporary", + "temporary.description": "What is inside is taken back at a checkpoint.", + "temporary.until": "Until checkpoint", + "temporary.until.description": "The next one when left out.", + "timeout": "Time limit", + "timeout.description": "What is inside gives up when the time is out.", + "timeout.time": "Time", + "use-block": "Use block", + "use-block.description": "Uses a block, as a player clicking it with the right button.", + "while": "Repeat while", + "while.condition": "Condition", + "while.description": "Plays what is inside over and over while the condition holds." +} diff --git a/addons/narrative/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json b/addons/narrative/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json new file mode 100644 index 000000000..6f4a6689c --- /dev/null +++ b/addons/narrative/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json @@ -0,0 +1,25 @@ +{ + "dialog.load_title": "Load cutscene", + "dialog.name": "Name", + "dialog.no_cutscenes": "No saved cutscenes", + "dialog.save_title": "Save cutscene", + "keyframe.world": "World", + "keyframe.world_rotation": "World rotation", + "origin.move_here": "Move here", + "origin.rebase": "Re-anchor", + "origin.x": "Origin X", + "origin.y": "Origin Y", + "origin.yaw": "Yaw", + "origin.z": "Origin Z", + "preview.camera_off": "Camera Off", + "preview.camera_on": "Camera On", + "preview.current_time": "Current time", + "preview.duration": "Duration", + "preview.paused": "Paused", + "preview.playing": "Playing", + "section.origin": "Origin", + "section.preview": "Preview", + "weather.clear": "Clear", + "weather.rain": "Rain", + "weather.thunder": "Thunder" +} diff --git a/addons/narrative/src/main/lang/en_us/hollowengine/gui/ide/story.json b/addons/narrative/src/main/lang/en_us/hollowengine/gui/ide/story.json new file mode 100644 index 000000000..4425217bf --- /dev/null +++ b/addons/narrative/src/main/lang/en_us/hollowengine/gui/ide/story.json @@ -0,0 +1,150 @@ +{ + "add.choice": "Choice", + "add.condition": "Condition", + "add.loop": "For each", + "add.phrase": "Phrase", + "add.start": "Start", + "add.values": "Values", + "add.variable": "Variable", + "add.variable.read": "Read %s", + "add.variable.write": "Set %s", + "branch.else": "else", + "branch.placeholder": "condition", + "branch.then": "Yes", + "call.result": "Result", + "category.animation": "Animation", + "category.camera": "Camera", + "category.data": "Data", + "category.dialogue": "Dialogue", + "category.effects": "Effects", + "category.effects.materials": "Materials", + "category.effects.screen": "Screen", + "category.effects.sound": "Sound", + "category.effects.vfx": "Particles", + "category.flow": "Flow", + "category.items": "Items", + "category.list": "Lists and maps", + "category.logic": "Logic", + "category.math": "Math", + "category.navigation": "Navigation", + "category.npc": "NPC", + "category.story": "Story", + "category.text": "Text", + "category.time": "Time", + "category.tracks": "Tasks", + "category.variables": "Variables", + "category.vector": "Vectors", + "category.world": "World", + "choice.add": "Add option", + "code.close": "Close", + "code.help": "Kotlin code placed in the story as it is.", + "code.open": "Open in the editor", + "code.text": "Kotlin", + "color.blue": "Blue", + "color.gray": "Gray", + "color.green": "Green", + "color.orange": "Orange", + "color.pick": "Pick a color", + "color.purple": "Purple", + "color.red": "Red", + "color.teal": "Teal", + "color.yellow": "Yellow", + "graph": "Graph", + "group.color": "Color", + "group.title": "Title", + "import.add": "Add an import", + "imports": "Imports", + "kind.block": "Block", + "kind.choice": "Choice", + "kind.code": "Code", + "kind.command": "Command", + "kind.condition": "Condition", + "kind.jump": "Jump", + "kind.loop": "For each", + "kind.phrase": "Phrase", + "kind.set": "Set variable", + "kind.start": "Start", + "kind.wait": "Wait", + "kotlin": "Generated Kotlin", + "list": "Cards", + "loop.body": "Do", + "loop.item": "Item", + "loop.items": "List", + "narrator": "Narrator", + "new.option": "New option", + "option.condition": "Shown if", + "option.condition.help": "The condition that offers this option, the if= of @choice.", + "param.default": "Default: %s", + "param.linked": "from a link", + "param.optional": "Optional: can be left empty.", + "phrase.format": "Text markup: bold, italic, underlined, struck, color, gradient, , , , , , , ,
for a new line.", + "phrase.placeholder": "What is said", + "phrase.speaker": "Speaker", + "phrase.speaker.help": "Who says the line: a name, or {expression} for an entity. Empty is the narrator.", + "phrase.text": "Text", + "phrase.text.help": "{name} puts in a variable, {player} the name of the player, {mod.key} a translation. A whole line can be a translation key too.", + "picks.open": "Pick from the list", + "pin.any": "Any type", + "pin.flow": "Story flow", + "problem.bool": "true, false or an expression goes here", + "problem.animation_none": "This model has no animations", + "problem.animation_unknown": "This model has no animation \"%s\"", + "problem.animation_similar": "This model has no animation \"%s\". Did you mean: %s?", + "problem.code": "The expression is unfinished: check its brackets and quotes", + "problem.duration": "A duration goes here: 500 (ms), 2s or 1m", + "problem.empty_text": "The line is empty", + "problem.graph.bad_id": "A node id may only hold letters, digits and _", + "problem.graph.bad_stage": "'%s' is not a name of a stage", + "problem.graph.bad_variable": "'%s' is not a name of a variable", + "problem.graph.code_outputs": "Code in the flow gives values through its outputs", + "problem.graph.gives_nothing": "This node gives no value", + "problem.graph.loop_item": "The item of a loop only exists in the body of that loop", + "problem.graph.loop_item_merged": "Flow gets here through a node several links lead to, which runs apart from the loop and cannot read its item: put the item into a variable first", + "problem.graph.many_next": "Pin '%s' continues to several nodes", + "problem.graph.many_values": "Pin '%s' reads several values", + "problem.graph.missing_node": "A link joins a missing node: %s", + "problem.graph.needs_return": "'%s' is read later, so it needs its return type", + "problem.graph.needs_start": "'%s' needs a value to start with", + "problem.graph.no_command": "There is no command '%s'", + "problem.graph.no_operator_value": "There is no value '%s' of '%s'", + "problem.graph.no_output": "There is no output '%s'", + "problem.graph.no_value": "Pin '%s' has no value", + "problem.graph.no_variable": "There is no variable '%s'", + "problem.graph.other_branch": "Flow can get here without passing the node this value comes from", + "problem.graph.own_value": "This node reads its own value", + "problem.graph.shared_id": "Two nodes share this id", + "problem.graph.shared_start": "Two starts share a name", + "problem.graph.value_in_flow": "A value has no place in the flow", + "problem.location": "A path like namespace:path goes here, in lowercase", + "problem.name": "A name holds letters, digits and _, starts with no digit and is no word of the language", + "problem.name_taken": "This name is taken", + "problem.no_variable": "There is no such variable", + "problem.required": "Fill this in", + "problem.unreachable": "The story never gets here, so this node will not run", + "remove": "Remove", + "reroute": "Reroute", + "reroute.help": "A point a link passes through, to lead it around other nodes. Drag it to move it, Alt and drag to pull another link out of it, double-click a link to put one on it. Deleting it joins the link back up.", + "search.close": "Close (Esc)", + "search.count": "%s of %s", + "search.next": "Next match (Enter)", + "search.none": "No matches", + "search.placeholder": "Find in story", + "search.previous": "Previous match (Shift+Enter)", + "set.result": "result", + "set.result.help": "The value once written. The node can stay out of the flow: it writes when its value is read.", + "set.value": "value", + "set.variable": "variable", + "start.name": "Name", + "story": "Story", + "variable.add": "Add variable", + "variable.help": "A Kotlin expression, such as listOf(1, 2).", + "variable.initial": "Starts as", + "variable.name": "Name", + "variable.type": "Type", + "variable.type.help": "Any Kotlin type. With ? at the end the variable can be empty and needs no starting value.", + "variables": "Variables", + "vector.look": "Where I look", + "vector.position": "Where I stand", + "vector.story_player": "Player of the story (when it runs)", + "vector.zero": "Zero" +} diff --git a/addons/narrative/src/main/lang/en_us/hollowengine/story/operator.json b/addons/narrative/src/main/lang/en_us/hollowengine/story/operator.json new file mode 100644 index 000000000..3d17769fa --- /dev/null +++ b/addons/narrative/src/main/lang/en_us/hollowengine/story/operator.json @@ -0,0 +1,119 @@ +{ + "abs": "Absolute", + "add": "Add", + "and": "And", + "ceil": "Round up", + "clamp": "Clamp", + "cos": "Cosine", + "distance": "Distance", + "divide": "Divide", + "entity-attribute": "Attribute", + "entity-health": "Health", + "entity-position": "Position", + "entity-rotation": "Rotation and motion", + "entity-state": "Entity state", + "equal": "Equal", + "find-player": "Player by name", + "floor": "Round down", + "format-text": "Format text", + "format-text.format.description": "Text with places for values: %s is a value, %.2f a number with two decimals. %1$s puts a anywhere.", + "greater": "Greater", + "greater-equal": "Greater or equal", + "held-item": "Held item", + "is-null": "Is empty", + "join-text": "Join text", + "lerp": "Blend", + "less": "Less", + "less-equal": "Less or equal", + "list": "List", + "list-add": "Add to list", + "list-contains": "List contains", + "list-get": "List item", + "list-remove": "Remove from list", + "list-size": "List size", + "map-get": "Map value", + "map-has": "Map has key", + "map-keys": "Map keys", + "map-put": "Put in map", + "map-remove": "Remove from map", + "max": "Largest", + "min": "Smallest", + "modulo": "Remainder", + "multiply": "Multiply", + "not": "Not", + "not-equal": "Not equal", + "or": "Or", + "pin.alive": "Alive", + "pin.attribute": "Attribute", + "pin.condition": "Condition", + "pin.count": "Count", + "pin.day_time": "Time of day", + "pin.dimension": "Dimension", + "pin.else": "If no", + "pin.entity": "Entity", + "pin.eyes": "Eyes", + "pin.factor": "Factor", + "pin.food": "Food", + "pin.format": "Format", + "pin.from": "From", + "pin.game_mode": "Game mode", + "pin.health": "Health", + "pin.held_item": "Held item", + "pin.in_water": "In water", + "pin.index": "Index", + "pin.item": "Item", + "pin.key": "Key", + "pin.keys": "Keys", + "pin.length": "Length", + "pin.level": "World", + "pin.list": "List", + "pin.look": "Look", + "pin.map": "Map", + "pin.max": "Max", + "pin.max_health": "Max health", + "pin.min": "Min", + "pin.name": "Name", + "pin.normalized": "Direction", + "pin.on_ground": "On ground", + "pin.part": "Part", + "pin.pitch": "Rotation (X)", + "pin.player": "Player", + "pin.position": "Position", + "pin.raining": "Raining", + "pin.result": "Result", + "pin.size": "Size", + "pin.sneaking": "Sneaking", + "pin.sprinting": "Sprinting", + "pin.t": "Blend", + "pin.text": "Text", + "pin.then": "If yes", + "pin.to": "To", + "pin.uuid": "UUID", + "pin.value": "Value", + "pin.vector": "Vector", + "pin.velocity": "Velocity", + "pin.yaw": "Rotation (Y)", + "player": "Story context", + "player-state": "Player state", + "power": "Power", + "random": "Random", + "range": "Numbers from to", + "round": "Round", + "select": "Pick by condition", + "sin": "Sine", + "sqrt": "Square root", + "subtract": "Subtract", + "text-contains": "Text contains", + "text-length": "Text length", + "text-replace": "Replace in text", + "to-number": "To number", + "to-text": "To text", + "vector": "Vector", + "vector-add": "Add vectors", + "vector-length": "Vector length", + "vector-lerp": "Blend vectors", + "vector-scale": "Scale vector", + "vector-split": "Vector parts", + "vector-subtract": "Subtract vectors", + "xor": "Exactly one" +} diff --git a/addons/narrative/src/main/lang/en_us/hollowengine/story/param.json b/addons/narrative/src/main/lang/en_us/hollowengine/story/param.json new file mode 100644 index 000000000..e9fb93770 --- /dev/null +++ b/addons/narrative/src/main/lang/en_us/hollowengine/story/param.json @@ -0,0 +1,67 @@ +{ + "animation": "Animation", + "arrivalDistance": "Stop at", + "arrivalDistance.description": "How close is close enough, in blocks.", + "color": "Color", + "color.description": "A color like #000000.", + "duration": "Duration", + "duration.description": "In milliseconds; 2s or 1m work too.", + "effect": "Effect", + "entity": "Entity", + "face": "Face", + "face.description": "The side of the block it uses; the one facing it when left out.", + "fade": "Blend", + "fade.description": "How long the animation blends in or out.", + "fadeIn": "Fade in", + "fadeOut": "Fade out", + "hand": "Hand", + "handle": "Task", + "id": "Id", + "immediate": "At once", + "level": "Dimension", + "level.description": "Where it happens; the dimension of the first player in the scene when left out.", + "material": "Material", + "maxAngularSpeed": "Turn speed", + "maxAngularSpeed.description": "Degrees per second at most.", + "model": "Model", + "montage": "Montage", + "name": "Name", + "passDistance": "Pass at", + "passDistance.description": "How close to a point on the way counts as passed, in blocks.", + "pitch": "Pitch", + "player": "Player", + "points": "Points", + "points.description": "The points to pass in order, [[x, y, z], ...].", + "pos": "Position", + "pos.description": "A point in the world, [x, y, z].", + "position": "Position", + "position.description": "A point in the world, [x, y, z].", + "reach": "Reach", + "reach.description": "How far it reaches, in blocks.", + "repathIntervalTicks": "Repath every", + "repathIntervalTicks.description": "Ticks between looking for a new path.", + "requireCorrectTool": "Needs the right tool", + "sound": "Sound", + "source": "File", + "speaker": "Speaker", + "speed": "Speed", + "speed.description": "1 is the normal walking speed.", + "stuckTimeoutTicks": "Stuck after", + "stuckTimeoutTicks.description": "Ticks without progress before it counts as stuck.", + "target": "Target", + "targetMoveThreshold": "Follow after", + "targetMoveThreshold.description": "How far the target moves before the path is found again, in blocks.", + "text": "Text", + "texture": "Texture", + "time": "Time", + "time.description": "In milliseconds; 2s or 1m work too.", + "to": "To", + "to.description": "The players it is for; everyone in the scene when left out.", + "unavailableTarget": "When the target is gone", + "unreachable": "When unreachable", + "unreachableTimeoutTicks": "Give up after", + "unreachableTimeoutTicks.description": "Ticks of an unreachable target before giving up.", + "volume": "Volume", + "who": "Who", + "who.description": "The character or entity that does it." +} diff --git a/addons/narrative/src/main/lang/en_us/index.json b/addons/narrative/src/main/lang/en_us/index.json new file mode 100644 index 000000000..a4ffeaba7 --- /dev/null +++ b/addons/narrative/src/main/lang/en_us/index.json @@ -0,0 +1,22 @@ +{ + "hollowengine.commands.dialogue_no_such_option": "There is no option %s in the dialogue you are in.", + "hollowengine.commands.dialogue_not_in_dialogue": "You are not taking part in a dialogue right now.", + "hollowengine.commands.story.checkpoint": "%s. %s (%s)", + "hollowengine.commands.story.entry": "%s: %s", + "hollowengine.commands.story.failed": "The story refused: %s", + "hollowengine.commands.story.missing": "There is no story %s here.", + "hollowengine.commands.story.none": "There are no stories here.", + "hollowengine.commands.story.rewound": "Story %s is going back to checkpoint %s.", + "hollowengine.commands.story.rewound_latest": "Story %s is going back to the latest checkpoint it passed.", + "hollowengine.commands.story.started": "Story %s started at stage %s.", + "hollowengine.commands.story.started_scene": "Story %s started: %s.", + "hollowengine.commands.story.stopped": "Story %s stopped.", + "hollowengine.gui.history.story.add": "Add card", + "hollowengine.gui.history.story.duplicate": "Duplicate card", + "hollowengine.gui.history.story.edit": "Edit card", + "hollowengine.gui.history.story.link": "Link change", + "hollowengine.gui.history.story.move": "Move card", + "hollowengine.gui.history.story.remove": "Remove card", + "hollowengine.gui.history.timeline.move_cutscene": "Move cutscene", + "hollowengine.gui.history.timeline.reanchor_cutscene": "Re-anchor cutscene" +} diff --git a/addons/narrative/src/main/lang/ru_ru/hollowengine-narrative/story/command.json b/addons/narrative/src/main/lang/ru_ru/hollowengine-narrative/story/command.json new file mode 100644 index 000000000..8c3090d23 --- /dev/null +++ b/addons/narrative/src/main/lang/ru_ru/hollowengine-narrative/story/command.json @@ -0,0 +1,167 @@ +{ + "async": "ΠŸΠ°Ρ€Π°Π»Π»Π΅Π»ΡŒΠ½ΠΎ", + "async.description": "Π˜Π³Ρ€Π°Π΅Ρ‚ Π²Π»ΠΎΠΆΠ΅Π½Π½ΠΎΠ΅ ΠΈΠ»ΠΈ ΠΎΠ΄Π½Ρƒ ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ Π½Π° своСй Π΄ΠΎΡ€ΠΎΠΆΠΊΠ΅, ΠΏΠΎΠΊΠ° история ΠΈΠ΄Ρ‘Ρ‚ дальшС.", + "async.track": "Π”ΠΎΡ€ΠΎΠΆΠΊΠ°", + "async.track.description": "Имя, ΠΏΠΎ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠΌΡƒ Π΄ΠΎΡ€ΠΎΠΆΠΊΡƒ Π·Π½Π°ΡŽΡ‚ @await ΠΈ @cancel.", + "avoid": "ΠžΠ±Ρ…ΠΎΠ΄ΠΈΡ‚ΡŒ Π·ΠΎΠ½Ρƒ", + "avoid.description": "ΠŸΡ€ΠΎΠΊΠ»Π°Π΄Ρ‹Π²Π°Π΅Ρ‚ ΠΏΡƒΡ‚ΡŒ Π² ΠΎΠ±Ρ…ΠΎΠ΄ Π·ΠΎΠ½Ρ‹.", + "await": "Π”ΠΎΠΆΠ΄Π°Ρ‚ΡŒΡΡ Π΄ΠΎΡ€ΠΎΠΆΠ΅ΠΊ", + "await.description": "Π–Π΄Ρ‘Ρ‚ ΠΊΠΎΠ½Ρ†Π° Π΄ΠΎΡ€ΠΎΠΆΠ΅ΠΊ; всСх, Ссли ΠΈΠΌΠ΅Π½Π° Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½Ρ‹.", + "await.tracks": "Π”ΠΎΡ€ΠΎΠΆΠΊΠΈ", + "await.tracks.description": "ИмСна Ρ‡Π΅Ρ€Π΅Π· ΠΏΡ€ΠΎΠ±Π΅Π».", + "call": "Π‘Ρ‹Π³Ρ€Π°Ρ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΡƒ", + "call-define": "Π’Ρ‹Π·Π²Π°Ρ‚ΡŒ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΡŽ", + "call-define.args": "АргумСнты", + "call-define.description": "Π˜Π³Ρ€Π°Π΅Ρ‚ @define ΠΈ, Ссли Ρ‚Π° Π²Π΅Ρ€Π½ΡƒΠ»Π° Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅, ΠΊΠ»Π°Π΄Ρ‘Ρ‚ Π΅Π³ΠΎ Π² ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ.", + "call-define.name": "Ѐункция", + "call-define.result": "Π’ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ", + "call.description": "Π˜Π³Ρ€Π°Π΅Ρ‚ с ΠΌΠ΅Ρ‚ΠΊΠΈ Π΄ΠΎ @return ΠΈ возвращаСтся ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ.", + "call.target": "#ΠœΠ΅Ρ‚ΠΊΠ° ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ».story#ΠœΠ΅Ρ‚ΠΊΠ°", + "camera": "ΠšΠ°ΠΌΠ΅Ρ€Π°", + "camera.description": "ΠŸΠ΅Ρ€Π΅Π½ΠΎΡΠΈΡ‚ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ ΠΈΠ³Ρ€ΠΎΠΊΠ° Π² Ρ‚ΠΎΡ‡ΠΊΡƒ ΠΈ ΠΏΠΎΠ²ΠΎΡ€Π°Ρ‡ΠΈΠ²Π°Π΅Ρ‚ Π΅Ρ‘.", + "camera.fov": "Π£Π³ΠΎΠ» ΠΎΠ±Π·ΠΎΡ€Π°", + "camera.location": "ΠœΠ΅ΡΡ‚ΠΎ", + "camera.rotation": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚", + "camera.rotation.description": "[ΠΏΠΎΠ²ΠΎΡ€ΠΎΡ‚ Y, ΠΏΠΎΠ²ΠΎΡ€ΠΎΡ‚ X] Π² градусах.", + "cancel": "ΠžΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π·Π°Π΄Π°Ρ‡Ρƒ", + "cancel.description": "ΠžΡΡ‚Π°Π½Π°Π²Π»ΠΈΠ²Π°Π΅Ρ‚ Π·Π°Π΄Π°Ρ‡Ρƒ, Π·Π°ΠΏΡƒΡ‰Π΅Π½Π½ΡƒΡŽ Π±Π»ΠΎΠΊΠΎΠΌ Β«ΠŸΠ°Ρ€Π°Π»Π»Π΅Π»ΡŒΠ½ΠΎΒ».", + "cancel.track": "Π”ΠΎΡ€ΠΎΠΆΠΊΠ°", + "change-sound": "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π·Π²ΡƒΠΊ", + "checkpoint": "Π§Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚", + "checkpoint.description": "Π’ΠΎΡ‡ΠΊΠ°, ΠΊ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠΉ история ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠ΅Ρ€Π΅ΠΌΠΎΡ‚Π°Ρ‚ΡŒΡΡ.", + "checkpoint.name": "Имя", + "choose": "Π’Ρ‹Π±ΠΎΡ€", + "choose.description": "ΠŸΡ€Π΅Π΄Π»Π°Π³Π°Π΅Ρ‚ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π½ΠΎΠΌΠ΅Ρ€ Π²Ρ‹Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ, с 0.", + "choose.options": "Π’Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹", + "command": "Команда сСрвСра", + "command.description": "ВыполняСт Π²Π°Π½ΠΈΠ»ΡŒΠ½ΡƒΡŽ ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ; {ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅} ΠΏΠΎΠ΄ΡΡ‚Π°Π²Π»ΡΡŽΡ‚ΡΡ.", + "command.text": "Команда", + "cutscene": "ΠšΠ°Ρ‚ΡΡ†Π΅Π½Π°", + "cutscene.description": "ΠŸΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Π΅Ρ‚ катсцСну ΠΈ ΠΆΠ΄Ρ‘Ρ‚ Π΅Ρ‘ ΠΊΠΎΠ½Ρ†Π°.", + "cutscene.name": "ΠšΠ°Ρ‚ΡΡ†Π΅Π½Π°", + "cutscene.time": "Π”Π»ΠΈΠ½Π°", + "cutscene.time.description": "Бколько ΠΏΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Ρ‚ΡŒ; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ΠΎ - Ρ†Π΅Π»ΠΈΠΊΠΎΠΌ.", + "define": "Ѐункция", + "define.description": "Π‘Π»ΠΎΠΊ, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ @call ΠΈΠ³Ρ€Π°Π΅Ρ‚ ΠΏΠΎ ΠΈΠΌΠ΅Π½ΠΈ; ΠΏΡ€ΠΈ Ρ‡Ρ‚Π΅Π½ΠΈΠΈ свСрху Π²Π½ΠΈΠ· пропускаСтся.", + "define.name": "Имя", + "define.params": "ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹", + "define.params.description": "ИмСна Ρ‡Π΅Ρ€Π΅Π· Π·Π°ΠΏΡΡ‚ΡƒΡŽ.", + "despawn-npc": "Π£Π±Ρ€Π°Ρ‚ΡŒ NPC", + "destroy-block": "Π‘Π»ΠΎΠΌΠ°Ρ‚ΡŒ Π±Π»ΠΎΠΊ", + "destroy-block.description": "Π›ΠΎΠΌΠ°Π΅Ρ‚ Π±Π»ΠΎΠΊ Π² ΠΏΡ€Π΅Π΄Π΅Π»Π°Ρ… досягаСмости.", + "fade-in": "Π—Π°Ρ‚Π΅ΠΌΠ½Π΅Π½ΠΈΠ΅", + "fade-in.description": "Π—Π°ΠΊΡ€Ρ‹Π²Π°Π΅Ρ‚ экран Ρ†Π²Π΅Ρ‚ΠΎΠΌ.", + "fade-out": "Из затСмнСния", + "fade-out.description": "Π£Π±ΠΈΡ€Π°Π΅Ρ‚ Ρ†Π²Π΅Ρ‚ с экрана.", + "find-npc": "Найти NPC", + "hide-hud": "Π‘ΠΊΡ€Ρ‹Ρ‚ΡŒ HUD", + "hide-hud.description": "Π‘ΠΊΡ€Ρ‹Π²Π°Π΅Ρ‚ интСрфСйс Ρ†Π΅Π»ΠΈΠΊΠΎΠΌ ΠΈΠ»ΠΈ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρ‡Π°ΡΡ‚ΡŒ слоёв.", + "hide-hud.except": "ΠšΡ€ΠΎΠΌΠ΅", + "hide-hud.except.description": "КакиС слои ΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ.", + "hide-hud.only": "Волько", + "hide-hud.only.description": "КакиС слои ΡΠΊΡ€Ρ‹Ρ‚ΡŒ; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½Ρ‹ - всС.", + "import": "Π˜ΠΌΠΏΠΎΡ€Ρ‚", + "import.description": "Π”Π°Ρ‘Ρ‚ @call ΠΈΠ³Ρ€Π°Ρ‚ΡŒ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ Π΄Ρ€ΡƒΠ³ΠΎΠΉ истории.", + "import.story": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ", + "interact": "Π’Π·Π°ΠΈΠΌΠΎΠ΄Π΅ΠΉΡΡ‚Π²ΠΎΠ²Π°Ρ‚ΡŒ", + "interact.description": "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ ΡΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ, ΠΊΠ°ΠΊ ΠΈΠ³Ρ€ΠΎΠΊ ΠΏΡ€Π°Π²ΠΎΠΉ ΠΊΠ½ΠΎΠΏΠΊΠΎΠΉ.", + "inventory": "Π—Π°ΠΏΠΎΠΌΠ½ΠΈΡ‚ΡŒ ΠΈΠ½Π²Π΅Π½Ρ‚Π°Ρ€ΡŒ", + "inventory.description": "Π§Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚ Π·Π°ΠΏΠΎΠΌΠΈΠ½Π°Π΅Ρ‚ ΠΈΠ½Π²Π΅Π½Ρ‚Π°Ρ€ΡŒ ΠΈΠ³Ρ€ΠΎΠΊΠ° ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π΅Π³ΠΎ ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΌΠΎΡ‚ΠΊΠ΅.", + "join": "Π”ΠΎΠΆΠ΄Π°Ρ‚ΡŒΡΡ Π·Π°Π΄Π°Ρ‡ΠΈ", + "jump": "ΠŸΠ΅Ρ€Π΅ΠΉΡ‚ΠΈ ΠΊ ΠΌΠ΅Ρ‚ΠΊΠ΅", + "jump.description": "ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅Ρ‚ с ΠΌΠ΅Ρ‚ΠΊΠΈ здСсь ΠΈΠ»ΠΈ Π² Π΄Ρ€ΡƒΠ³ΠΎΠΉ истории ΠΈ Π½Π΅ возвращаСтся.", + "jump.target": "#ΠœΠ΅Ρ‚ΠΊΠ° ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ».story#ΠœΠ΅Ρ‚ΠΊΠ°", + "look-at": "ΠŸΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π½Π° Ρ‚ΠΎΡ‡ΠΊΡƒ", + "look-at-entity": "ΠŸΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π½Π° ΡΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ", + "look-at-entity.description": "ΠŸΠΎΠ²ΠΎΡ€Π°Ρ‡ΠΈΠ²Π°Π΅Ρ‚ΡΡ ΠΊ сущности ΠΈ слСдит Π·Π° Π½Π΅ΠΉ взглядом.", + "look-at-entity.target": "На ΠΊΠΎΠ³ΠΎ", + "look-at.description": "ΠŸΠΎΠ²ΠΎΡ€Π°Ρ‡ΠΈΠ²Π°Π΅Ρ‚ΡΡ ΠΊ Ρ‚ΠΎΡ‡ΠΊΠ΅.", + "move-through": "ΠŸΡ€ΠΎΠΉΡ‚ΠΈ ΠΏΠΎ Ρ‚ΠΎΡ‡ΠΊΠ°ΠΌ", + "move-through.description": "ΠŸΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ Ρ‡Π΅Ρ€Π΅Π· Ρ‚ΠΎΡ‡ΠΊΠΈ ΠΏΠΎ порядку.", + "move-to": "ΠŸΡ€ΠΈΠΉΡ‚ΠΈ ΠΊ Ρ‚ΠΎΡ‡ΠΊΠ΅", + "move-to-entity": "ΠŸΡ€ΠΈΠΉΡ‚ΠΈ ΠΊ сущности", + "move-to-entity.description": "Π˜Π΄Ρ‘Ρ‚ ΠΊ сущности, слСдуя Π·Π° Π½Π΅ΠΉ, ΠΈ ΠΆΠ΄Ρ‘Ρ‚, ΠΏΠΎΠΊΠ° Π½Π΅ Π΄ΠΎΠΉΠ΄Ρ‘Ρ‚.", + "move-to.description": "Π˜Π΄Ρ‘Ρ‚ ΠΊ Ρ‚ΠΎΡ‡ΠΊΠ΅ ΠΈ ΠΆΠ΄Ρ‘Ρ‚, ΠΏΠΎΠΊΠ° Π½Π΅ Π΄ΠΎΠΉΠ΄Ρ‘Ρ‚.", + "move-vfx": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅ΡΡ‚ΠΈΡ‚ΡŒ эффСкт", + "next-tick": "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠΉ Ρ‚ΠΈΠΊ", + "npc": "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ NPC", + "npc.description": "Π‘Ρ‚Π°Π²ΠΈΡ‚ NPC Π² ΠΌΠΈΡ€ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π΅Π³ΠΎ; ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΌΠΎΡ‚ΠΊΠ΅ истории Π½Π°Π·Π°Π΄ ΡƒΠ±ΠΈΡ€Π°Π΅Ρ‚.", + "npc.id": "Id", + "npc.id.description": "Какого NPC ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ village:guard; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ - всСгда Π½ΠΎΠ²ΠΎΠ³ΠΎ.", + "play-clamped": "Анимация с остановкой", + "play-clamped.description": "ΠŸΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΡŽ ΠΈ Π·Π°ΠΌΠΈΡ€Π°Π΅Ρ‚ Π½Π° послСднСм ΠΊΠ°Π΄Ρ€Π΅.", + "play-looped": "Анимация ΠΏΠΎ ΠΊΡ€ΡƒΠ³Ρƒ", + "play-looped.description": "ΠŸΠΎΠ²Ρ‚ΠΎΡ€ΡΠ΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΡŽ, ΠΏΠΎΠΊΠ° Π΅Ρ‘ Π½Π΅ остановят.", + "play-montage": "ΠŸΡ€ΠΎΠΈΠ³Ρ€Π°Ρ‚ΡŒ ΠΌΠΎΠ½Ρ‚Π°ΠΆ", + "play-once": "Анимация", + "play-once.description": "ΠŸΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΡŽ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π· ΠΈ ΠΆΠ΄Ρ‘Ρ‚ Π΅Ρ‘ ΠΊΠΎΠ½Ρ†Π°.", + "play-ping-pong": "Анимация Ρ‚ΡƒΠ΄Π°-ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ", + "play-ping-pong.description": "ΠŸΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΡŽ Π²ΠΏΠ΅Ρ€Ρ‘Π΄, ΠΏΠΎΡ‚ΠΎΠΌ Π½Π°Π·Π°Π΄, ΠΈ Ρ‚Π°ΠΊ ΠΏΠΎ ΠΊΡ€ΡƒΠ³Ρƒ.", + "play-sound": "Π—Π²ΡƒΠΊ", + "play-sound-named": "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ Π·Π²ΡƒΠΊ", + "play-sound.description": "ΠŸΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Π΅Ρ‚ Π·Π²ΡƒΠΊ ΠΈΠ³Ρ€ΠΎΠΊΡƒ.", + "play-sound.name": "Π—Π²ΡƒΠΊ", + "play-vfx": "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ эффСкт", + "play-vfx-on": "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ эффСкт Π½Π° сущности", + "play-video": "ΠŸΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ Π²ΠΈΠ΄Π΅ΠΎ", + "position": "Π—Π°ΠΏΠΎΠΌΠ½ΠΈΡ‚ΡŒ ΠΏΠΎΠ·ΠΈΡ†ΠΈΡŽ", + "position.description": "Π§Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚ Π·Π°ΠΏΠΎΠΌΠΈΠ½Π°Π΅Ρ‚, Π³Π΄Π΅ стоит ΡΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ, ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π΅Ρ‘ Ρ‚ΡƒΠ΄Π° ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΌΠΎΡ‚ΠΊΠ΅.", + "release-camera": "Π’Π΅Ρ€Π½ΡƒΡ‚ΡŒ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ", + "release-camera.description": "ΠžΡ‚Π΄Π°Ρ‘Ρ‚ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ ΠΈΠ³Ρ€ΠΎΠΊΡƒ.", + "reset-material": "Π’Π΅Ρ€Π½ΡƒΡ‚ΡŒ ΠΌΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»", + "reset-materials": "Π’Π΅Ρ€Π½ΡƒΡ‚ΡŒ всС ΠΌΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»Ρ‹", + "return": "Π’Π΅Ρ€Π½ΡƒΡ‚ΡŒΡΡ", + "return.description": "ВозвращаСтся Ρ‚ΡƒΠ΄Π°, ΠΎΡ‚ΠΊΡƒΠ΄Π° Π±Ρ‹Π» @call; ΠΈΠ· @define - со Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ΠΌ.", + "return.value": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "rewind": "ΠŸΠ΅Ρ€Π΅ΠΌΠΎΡ‚Π°Ρ‚ΡŒ", + "rewind.checkpoint": "Π§Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚", + "rewind.checkpoint.description": "Если Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ - послСдний ΠΏΡ€ΠΎΠΉΠ΄Π΅Π½Π½Ρ‹ΠΉ.", + "rewind.description": "ВозвращаСтся ΠΊ Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Ρƒ ΠΈ ΠΎΡ‚ΠΊΠ°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ сдСланноС послС Π½Π΅Π³ΠΎ.", + "say": "Π Π΅ΠΏΠ»ΠΈΠΊΠ°", + "say.description": "ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Ρ€Π΅ΠΏΠ»ΠΈΠΊΡƒ ΠΈ ΠΆΠ΄Ρ‘Ρ‚, ΠΏΠΎΠΊΠ° Π΅Ρ‘ ΠΏΡ€ΠΎΡ‡Ρ‚ΡƒΡ‚.", + "set": "Π—Π°Π΄Π°Ρ‚ΡŒ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ", + "set-material": "Π‘ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΌΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»", + "set.description": "ΠšΠ»Π°Π΄Ρ‘Ρ‚ Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ Π² ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ.", + "set.value": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "set.value.description": "Π’Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅; ΠΊΠΎΠΌΠ°Π½Π΄Π° Π²Ρ€ΠΎΠ΄Π΅ npc([0, 64, 0]) ΠΎΡ‚Π΄Π°Ρ‘Ρ‚ свой Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚.", + "set.variable": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Π°Ρ", + "show-hud": "ΠŸΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ HUD", + "show-hud.description": "Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ скрытый интСрфСйс.", + "spawn-npc": "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ NPC", + "spawn-npc.description": "Π‘Ρ‚Π°Π²ΠΈΡ‚ NPC Π² ΠΌΠΈΡ€ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π΅Π³ΠΎ; ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΌΠΎΡ‚ΠΊΠ΅ истории Π½Π°Π·Π°Π΄ ΡƒΠ±ΠΈΡ€Π°Π΅Ρ‚.", + "spawn-npc.id.description": "Какого NPC ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ village:guard; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ - всСгда Π½ΠΎΠ²ΠΎΠ³ΠΎ.", + "spawn-npc.model.description": "ΠŸΡƒΡ‚ΡŒ ΠΊ ΠΌΠΎΠ΄Π΅Π»ΠΈ NPC.", + "spawn-npc.name.description": "Имя Π½Π°Π΄ Π³ΠΎΠ»ΠΎΠ²ΠΎΠΉ NPC.", + "stage": "Бтадия скрипта", + "stage.args": "АргумСнты", + "stage.description": "Π˜Π³Ρ€Π°Π΅Ρ‚ @State-Ρ„ΡƒΠ½ΠΊΡ†ΠΈΡŽ скрипта ΠΈ ΠΆΠ΄Ρ‘Ρ‚ Π΅Ρ‘.", + "stage.target": "script.kts#стадия", + "start-following": "Π˜Π΄Ρ‚ΠΈ Π·Π° ΡΡƒΡ‰Π½ΠΎΡΡ‚ΡŒΡŽ", + "start-looking": "Π‘ΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π½Π° Ρ‚ΠΎΡ‡ΠΊΡƒ", + "start-montage": "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ ΠΌΠΎΠ½Ρ‚Π°ΠΆ", + "start-moving": "Π˜Π΄Ρ‚ΠΈ ΠΊ Ρ‚ΠΎΡ‡ΠΊΠ΅", + "start-watching": "Π‘ΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π½Π° ΡΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ", + "stop-all-animations": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ всС Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ", + "stop-animation": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΡŽ", + "stop-animation.description": "Плавно останавливаСт Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΡŽ.", + "stop-looking": "ΠŸΠ΅Ρ€Π΅ΡΡ‚Π°Ρ‚ΡŒ ΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ", + "stop-looking.description": "ΠŸΠ΅Ρ€Π΅ΡΡ‚Π°Ρ‘Ρ‚ ΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ‚ΡƒΠ΄Π°, ΠΊΡƒΠ΄Π° смотрСл.", + "stop-montage": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ ΠΌΠΎΠ½Ρ‚Π°ΠΆ", + "stop-moving": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒΡΡ", + "stop-moving.description": "ΠŸΠ΅Ρ€Π΅ΡΡ‚Π°Ρ‘Ρ‚ ΠΈΠ΄Ρ‚ΠΈ.", + "stop-sound": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ Π·Π²ΡƒΠΊ", + "stop-vfx": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ эффСкт", + "teleport": "Π’Π΅Π»Π΅ΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ", + "temporary": "Π’Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎ", + "temporary.description": "Π’Π»ΠΎΠΆΠ΅Π½Π½ΠΎΠ΅ откатываСтся Π½Π° Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Π΅.", + "temporary.until": "Π”ΠΎ Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Π°", + "temporary.until.description": "Если Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ - Π΄ΠΎ ΡΠ»Π΅Π΄ΡƒΡŽΡ‰Π΅Π³ΠΎ.", + "timeout": "ΠžΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠ΅ ΠΏΠΎ Π²Ρ€Π΅ΠΌΠ΅Π½ΠΈ", + "timeout.description": "Π’Π»ΠΎΠΆΠ΅Π½Π½ΠΎΠ΅ бросаСтся, ΠΊΠΎΠ³Π΄Π° врСмя Π²Ρ‹ΡˆΠ»ΠΎ.", + "timeout.time": "ВрСмя", + "use-block": "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π±Π»ΠΎΠΊ", + "use-block.description": "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ Π±Π»ΠΎΠΊ, ΠΊΠ°ΠΊ ΠΈΠ³Ρ€ΠΎΠΊ ΠΏΡ€Π°Π²ΠΎΠΉ ΠΊΠ½ΠΎΠΏΠΊΠΎΠΉ.", + "while": "ΠŸΠΎΠ²Ρ‚ΠΎΡ€ΡΡ‚ΡŒ, ΠΏΠΎΠΊΠ°", + "while.condition": "УсловиС", + "while.description": "ΠŸΠΎΠ²Ρ‚ΠΎΡ€ΡΠ΅Ρ‚ Π²Π»ΠΎΠΆΠ΅Π½Π½ΠΎΠ΅, ΠΏΠΎΠΊΠ° условиС Π²Π΅Ρ€Π½ΠΎ." +} diff --git a/addons/narrative/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json b/addons/narrative/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json new file mode 100644 index 000000000..15337ebc1 --- /dev/null +++ b/addons/narrative/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json @@ -0,0 +1,25 @@ +{ + "dialog.load_title": "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° катсцСны", + "dialog.name": "НазваниС", + "dialog.no_cutscenes": "Π‘ΠΎΡ…Ρ€Π°Π½Ρ‘Π½Π½Ρ‹Ρ… катсцСн Π½Π΅Ρ‚", + "dialog.save_title": "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ катсцСны", + "keyframe.world": "Π’ ΠΌΠΈΡ€Π΅", + "keyframe.world_rotation": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚ Π² ΠΌΠΈΡ€Π΅", + "origin.move_here": "ΠŸΠ΅Ρ€Π΅Π½Π΅ΡΡ‚ΠΈ сюда", + "origin.rebase": "ΠŸΠ΅Ρ€Π΅ΠΏΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ", + "origin.x": "Начало X", + "origin.y": "Начало Y", + "origin.yaw": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚", + "origin.z": "Начало Z", + "preview.camera_off": "ΠšΠ°ΠΌΠ΅Ρ€Π° Π²Ρ‹ΠΊΠ»", + "preview.camera_on": "ΠšΠ°ΠΌΠ΅Ρ€Π° Π²ΠΊΠ»", + "preview.current_time": "Π’Π΅ΠΊΡƒΡ‰Π΅Π΅ врСмя", + "preview.duration": "Π”Π»ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ", + "preview.paused": "ΠŸΠ°ΡƒΠ·Π°", + "preview.playing": "ВоспроизвСдСниС", + "section.origin": "Начало ΠΊΠΎΠΎΡ€Π΄ΠΈΠ½Π°Ρ‚", + "section.preview": "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€", + "weather.clear": "Ясно", + "weather.rain": "Π”ΠΎΠΆΠ΄ΡŒ", + "weather.thunder": "Π“Ρ€ΠΎΠ·Π°" +} diff --git a/addons/narrative/src/main/lang/ru_ru/hollowengine/gui/ide/story.json b/addons/narrative/src/main/lang/ru_ru/hollowengine/gui/ide/story.json new file mode 100644 index 000000000..5b72ece58 --- /dev/null +++ b/addons/narrative/src/main/lang/ru_ru/hollowengine/gui/ide/story.json @@ -0,0 +1,150 @@ +{ + "add.choice": "Π’Ρ‹Π±ΠΎΡ€", + "add.condition": "УсловиС", + "add.loop": "Для ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ", + "add.phrase": "Π€Ρ€Π°Π·Π°", + "add.start": "Начало", + "add.values": "ЗначСния", + "add.variable": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Π°Ρ", + "add.variable.read": "ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°Ρ‚ΡŒ %s", + "add.variable.write": "Π—Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ %s", + "branch.else": "ΠΈΠ½Π°Ρ‡Π΅", + "branch.placeholder": "условиС", + "branch.then": "Π”Π°", + "call.result": "Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚", + "category.animation": "Анимация", + "category.camera": "ΠšΠ°ΠΌΠ΅Ρ€Π°", + "category.data": "Π”Π°Π½Π½Ρ‹Π΅", + "category.dialogue": "Π”ΠΈΠ°Π»ΠΎΠ³", + "category.effects": "Π­Ρ„Ρ„Π΅ΠΊΡ‚Ρ‹", + "category.effects.materials": "ΠœΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»Ρ‹", + "category.effects.screen": "Π­ΠΊΡ€Π°Π½", + "category.effects.sound": "Π—Π²ΡƒΠΊ", + "category.effects.vfx": "Частицы", + "category.flow": "Π₯ΠΎΠ΄ истории", + "category.items": "ΠŸΡ€Π΅Π΄ΠΌΠ΅Ρ‚Ρ‹", + "category.list": "Бписки ΠΈ словари", + "category.logic": "Π›ΠΎΠ³ΠΈΠΊΠ°", + "category.math": "ΠœΠ°Ρ‚Π΅ΠΌΠ°Ρ‚ΠΈΠΊΠ°", + "category.navigation": "Навигация", + "category.npc": "NPC", + "category.story": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ", + "category.text": "ВСкст", + "category.time": "ВрСмя", + "category.tracks": "Π—Π°Π΄Π°Ρ‡ΠΈ", + "category.variables": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅", + "category.vector": "Π’Π΅ΠΊΡ‚ΠΎΡ€Ρ‹", + "category.world": "ΠœΠΈΡ€", + "choice.add": "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚", + "code.close": "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ", + "code.help": "Код Π½Π° Kotlin, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΏΠΎΠΏΠ°Π΄Π°Π΅Ρ‚ Π² ΠΈΡΡ‚ΠΎΡ€ΠΈΡŽ ΠΊΠ°ΠΊ Π΅ΡΡ‚ΡŒ.", + "code.open": "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π΅", + "code.text": "Kotlin", + "color.blue": "Π‘ΠΈΠ½ΠΈΠΉ", + "color.gray": "Π‘Π΅Ρ€Ρ‹ΠΉ", + "color.green": "Π—Π΅Π»Ρ‘Π½Ρ‹ΠΉ", + "color.orange": "ΠžΡ€Π°Π½ΠΆΠ΅Π²Ρ‹ΠΉ", + "color.pick": "Π’Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Ρ†Π²Π΅Ρ‚", + "color.purple": "Π€ΠΈΠΎΠ»Π΅Ρ‚ΠΎΠ²Ρ‹ΠΉ", + "color.red": "ΠšΡ€Π°ΡΠ½Ρ‹ΠΉ", + "color.teal": "Π‘ΠΈΡ€ΡŽΠ·ΠΎΠ²Ρ‹ΠΉ", + "color.yellow": "Π–Ρ‘Π»Ρ‚Ρ‹ΠΉ", + "graph": "Π“Ρ€Π°Ρ„", + "group.color": "Π¦Π²Π΅Ρ‚", + "group.title": "НазваниС", + "import.add": "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΈΠΌΠΏΠΎΡ€Ρ‚", + "imports": "Π˜ΠΌΠΏΠΎΡ€Ρ‚Ρ‹", + "kind.block": "Π‘Π»ΠΎΠΊ", + "kind.choice": "Π’Ρ‹Π±ΠΎΡ€", + "kind.code": "Код", + "kind.command": "Команда", + "kind.condition": "УсловиС", + "kind.jump": "ΠŸΠ΅Ρ€Π΅Ρ…ΠΎΠ΄", + "kind.loop": "Для ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ", + "kind.phrase": "Π€Ρ€Π°Π·Π°", + "kind.set": "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ", + "kind.start": "Начало", + "kind.wait": "ОТиданиС", + "kotlin": "Π‘Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹ΠΉ Kotlin", + "list": "ΠšΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ", + "loop.body": "Π’Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ", + "loop.item": "Π­Π»Π΅ΠΌΠ΅Π½Ρ‚", + "loop.items": "Бписок", + "narrator": "Рассказчик", + "new.option": "Новый Π²Π°Ρ€ΠΈΠ°Π½Ρ‚", + "option.condition": "ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ, Ссли", + "option.condition.help": "УсловиС, ΠΏΡ€ΠΈ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠΌ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ прСдлагаСтся (if= Ρƒ @choice)", + "param.default": "По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ: %s", + "param.linked": "ΠΈΠ· связи", + "param.optional": "ΠΠ΅ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ: ΠΌΠΎΠΆΠ½ΠΎ ΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ пустым.", + "phrase.format": "Π Π°Π·ΠΌΠ΅Ρ‚ΠΊΠ° тСкста: ΠΆΠΈΡ€Π½Ρ‹ΠΉ, курсив, ΠΏΠΎΠ΄Ρ‡Ρ‘Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ, Π·Π°Ρ‡Ρ‘Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ, Ρ†Π²Π΅Ρ‚, Π³Ρ€Π°Π΄ΠΈΠ΅Π½Ρ‚, , , , , , , ,
для Π½ΠΎΠ²ΠΎΠΉ строки.", + "phrase.placeholder": "Π§Ρ‚ΠΎ говорится", + "phrase.speaker": "Говорящий", + "phrase.speaker.help": "ΠšΡ‚ΠΎ Π³ΠΎΠ²ΠΎΡ€ΠΈΡ‚: имя ΠΈΠ»ΠΈ {Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅} для сущности. ΠŸΡƒΡΡ‚ΠΎ - рассказчик.", + "phrase.text": "ВСкст", + "phrase.text.help": "{имя} подставляСт ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ, {player} - имя ΠΈΠ³Ρ€ΠΎΠΊΠ°, {mod.key} - ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄. Вся Ρ€Π΅ΠΏΠ»ΠΈΠΊΠ° Ρ‚ΠΎΠΆΠ΅ ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ ΠΊΠ»ΡŽΡ‡ΠΎΠΌ ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄Π°.", + "picks.open": "Π’Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· списка", + "pin.any": "Π›ΡŽΠ±ΠΎΠΉ Ρ‚ΠΈΠΏ", + "pin.flow": "Π₯ΠΎΠ΄ истории", + "problem.bool": "НуТно true, false ΠΈΠ»ΠΈ Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅", + "problem.animation_none": "Π£ этой ΠΌΠΎΠ΄Π΅Π»ΠΈ Π½Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΉ", + "problem.animation_unknown": "Π£ этой ΠΌΠΎΠ΄Π΅Π»ΠΈ Π½Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ Β«%sΒ»", + "problem.animation_similar": "Π£ этой ΠΌΠΎΠ΄Π΅Π»ΠΈ Π½Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ Β«%sΒ». Π’ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ, Π²Ρ‹ ΠΈΠΌΠ΅Π»ΠΈ Π² Π²ΠΈΠ΄Ρƒ: %s?", + "problem.code": "Π’Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ Π½Π΅ Π·Π°ΠΊΠΎΠ½Ρ‡Π΅Π½ΠΎ: ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ скобки ΠΈ ΠΊΠ°Π²Ρ‹Ρ‡ΠΊΠΈ", + "problem.duration": "НуТна Π΄Π»ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ: 500 (мс), 2s ΠΈΠ»ΠΈ 1m", + "problem.empty_text": "Π Π΅ΠΏΠ»ΠΈΠΊΠ° пуста", + "problem.graph.bad_id": "Π’ id Π½ΠΎΠ΄Ρ‹ допустимы Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π±ΡƒΠΊΠ²Ρ‹, Ρ†ΠΈΡ„Ρ€Ρ‹ ΠΈ _", + "problem.graph.bad_stage": "Β«%sΒ» Π½Π΅ годится ΠΊΠ°ΠΊ имя стадии", + "problem.graph.bad_variable": "Β«%sΒ» Π½Π΅ годится ΠΊΠ°ΠΊ имя ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ", + "problem.graph.code_outputs": "Код Π² Ρ…ΠΎΠ΄Π΅ истории ΠΎΡ‚Π΄Π°Ρ‘Ρ‚ значСния Ρ‡Π΅Ρ€Π΅Π· свои Π²Ρ‹Ρ…ΠΎΠ΄Ρ‹", + "problem.graph.gives_nothing": "Π­Ρ‚Π° Π½ΠΎΠ΄Π° Π½Π΅ Π΄Π°Ρ‘Ρ‚ значСния", + "problem.graph.loop_item": "Π­Π»Π΅ΠΌΠ΅Π½Ρ‚ Ρ†ΠΈΠΊΠ»Π° сущСствуСт Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² Ρ‚Π΅Π»Π΅ этого Ρ†ΠΈΠΊΠ»Π°", + "problem.graph.loop_item_merged": "Бюда приходят Ρ‡Π΅Ρ€Π΅Π· Π½ΠΎΠ΄Ρƒ, ΠΊ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠΉ Π²Π΅Π΄Ρ‘Ρ‚ нСсколько связСй: ΠΎΠ½Π° выполняСтся ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎ ΠΎΡ‚ Ρ†ΠΈΠΊΠ»Π° ΠΈ Π½Π΅ Π²ΠΈΠ΄ΠΈΡ‚ Π΅Π³ΠΎ элСмСнт. Π‘Π½Π°Ρ‡Π°Π»Π° Π·Π°ΠΏΠΈΡˆΠΈΡ‚Π΅ элСмСнт Π² ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ", + "problem.graph.many_next": "Пин Β«%sΒ» Π²Π΅Π΄Ρ‘Ρ‚ сразу ΠΊ нСскольким Π½ΠΎΠ΄Π°ΠΌ", + "problem.graph.many_values": "Пин Β«%sΒ» Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ сразу нСсколько Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ", + "problem.graph.missing_node": "Бвязь Π²Π΅Π΄Ρ‘Ρ‚ ΠΊ Π½Π΅ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰Π΅ΠΉ Π½ΠΎΠ΄Π΅: %s", + "problem.graph.needs_return": "Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ Β«%sΒ» читаСтся дальшС, поэтому Π½ΡƒΠΆΠ΅Π½ Ρ‚ΠΈΠΏ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Π°", + "problem.graph.needs_start": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ Β«%sΒ» Π½ΡƒΠΆΠ½ΠΎ Π½Π°Ρ‡Π°Π»ΡŒΠ½ΠΎΠ΅ Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "problem.graph.no_command": "ΠšΠΎΠΌΠ°Π½Π΄Ρ‹ Β«%sΒ» Π½Π΅Ρ‚", + "problem.graph.no_operator_value": "Π£ Β«%2$sΒ» Π½Π΅Ρ‚ значСния Β«%1$sΒ»", + "problem.graph.no_output": "Π’Ρ‹Ρ…ΠΎΠ΄Π° Β«%sΒ» Π½Π΅Ρ‚", + "problem.graph.no_value": "Π£ ΠΏΠΈΠ½Π° Β«%sΒ» Π½Π΅Ρ‚ значСния", + "problem.graph.no_variable": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ Β«%sΒ» Π½Π΅Ρ‚", + "problem.graph.other_branch": "Бюда ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠΎΠΏΠ°ΡΡ‚ΡŒ, минуя Π½ΠΎΠ΄Ρƒ, ΠΎΡ‚ΠΊΡƒΠ΄Π° бСрётся это Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "problem.graph.own_value": "Π­Ρ‚Π° Π½ΠΎΠ΄Π° Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ своё ΠΆΠ΅ Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "problem.graph.shared_id": "Π­Ρ‚ΠΎΡ‚ id ΡƒΠΆΠ΅ Π΅ΡΡ‚ΡŒ Ρƒ Π΄Ρ€ΡƒΠ³ΠΎΠΉ Π½ΠΎΠ΄Ρ‹", + "problem.graph.shared_start": "Π£ Π΄Π²ΡƒΡ… стартов ΠΎΠ΄Π½ΠΎ имя", + "problem.graph.value_in_flow": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΡŽ Π½Π΅ мСсто Π² Ρ…ΠΎΠ΄Π΅ истории", + "problem.location": "НуТСн ΠΏΡƒΡ‚ΡŒ Π²ΠΈΠ΄Π° namespace:path, строчными Π±ΡƒΠΊΠ²Π°ΠΌΠΈ", + "problem.name": "Имя: Π±ΡƒΠΊΠ²Ρ‹, Ρ†ΠΈΡ„Ρ€Ρ‹ ΠΈ _, Π½Π΅ с Ρ†ΠΈΡ„Ρ€Ρ‹ ΠΈ Π½Π΅ слово языка", + "problem.name_taken": "Π’Π°ΠΊΠΎΠ΅ имя ΡƒΠΆΠ΅ Π΅ΡΡ‚ΡŒ", + "problem.no_variable": "Π’Π°ΠΊΠΎΠΉ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ Π½Π΅Ρ‚", + "problem.required": "НуТно Π·Π°ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ", + "problem.unreachable": "Бюда Π½Π΅ ΠΏΡ€ΠΈΡ…ΠΎΠ΄ΠΈΡ‚ Ρ…ΠΎΠ΄ истории, ΠΈ эта Π½ΠΎΠ΄Π° Π½Π΅ выполнится", + "remove": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ", + "reroute": "Π’ΠΎΡ‡ΠΊΠ° Π½Π° связи", + "reroute.help": "Π’ΠΎΡ‡ΠΊΠ°, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€ΡƒΡŽ ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ связь, Ρ‡Ρ‚ΠΎΠ±Ρ‹ обвСсти Π΅Ρ‘ Π²ΠΎΠΊΡ€ΡƒΠ³ Π΄Ρ€ΡƒΠ³ΠΈΡ… Π½ΠΎΠ΄. Π’Π°Ρ‰ΠΈΡ‚Π΅, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅ΡΡ‚ΠΈΡ‚ΡŒ, Alt ΠΈ пСрСтаскиваниС Π²Ρ‹Ρ‚ΡΠ³ΠΈΠ²Π°ΡŽΡ‚ Π½ΠΎΠ²ΡƒΡŽ связь, Π΄Π²ΠΎΠΉΠ½ΠΎΠΉ ΠΊΠ»ΠΈΠΊ ΠΏΠΎ связи ставит Π½ΠΎΠ²ΡƒΡŽ Ρ‚ΠΎΡ‡ΠΊΡƒ. ΠŸΡ€ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ связь соСдиняСтся ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ.", + "search.close": "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ (Esc)", + "search.count": "%s ΠΈΠ· %s", + "search.next": "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π΅Π΅ (Enter)", + "search.none": "НС Π½Π°ΠΉΠ΄Π΅Π½ΠΎ", + "search.placeholder": "Найти Π² истории", + "search.previous": "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π΅Π΅ (Shift+Enter)", + "set.result": "Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚", + "set.result.help": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ послС записи. Ноду ΠΌΠΎΠΆΠ½ΠΎ Π½Π΅ Π²ΡΡ‚Ρ€Π°ΠΈΠ²Π°Ρ‚ΡŒ Π² Ρ…ΠΎΠ΄ истории: запись случится, ΠΊΠΎΠ³Π΄Π° Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°ΡŽΡ‚.", + "set.value": "Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "set.variable": "пСрСмСнная", + "start.name": "Имя", + "story": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ", + "variable.add": "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ", + "variable.help": "Π’Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ Kotlin, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ listOf(1, 2).", + "variable.initial": "ΠΠ°Ρ‡Π°Π»ΡŒΠ½ΠΎΠ΅ Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "variable.name": "Имя", + "variable.type": "Π’ΠΈΠΏ", + "variable.type.help": "Π›ΡŽΠ±ΠΎΠΉ Ρ‚ΠΈΠΏ Kotlin. Π‘ ? Π² ΠΊΠΎΠ½Ρ†Π΅ пСрСмСнная ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ пустой, ΠΈ Π½Π°Ρ‡Π°Π»ΡŒΠ½ΠΎΠ΅ Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ Π½Π΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ.", + "variables": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅", + "vector.look": "ΠšΡƒΠ΄Π° я ΡΠΌΠΎΡ‚Ρ€ΡŽ", + "vector.position": "Мои ΠΊΠΎΠΎΡ€Π΄ΠΈΠ½Π°Ρ‚Ρ‹", + "vector.story_player": "Π˜Π³Ρ€ΠΎΠΊ истории (ΠΏΡ€ΠΈ запускС)", + "vector.zero": "Ноль" +} diff --git a/addons/narrative/src/main/lang/ru_ru/hollowengine/story/operator.json b/addons/narrative/src/main/lang/ru_ru/hollowengine/story/operator.json new file mode 100644 index 000000000..7a2fe2264 --- /dev/null +++ b/addons/narrative/src/main/lang/ru_ru/hollowengine/story/operator.json @@ -0,0 +1,119 @@ +{ + "abs": "ΠœΠΎΠ΄ΡƒΠ»ΡŒ", + "add": "Π‘Π»ΠΎΠΆΠΈΡ‚ΡŒ", + "and": "И", + "ceil": "ΠžΠΊΡ€ΡƒΠ³Π»ΠΈΡ‚ΡŒ Π²Π²Π΅Ρ€Ρ…", + "clamp": "ΠžΠ³Ρ€Π°Π½ΠΈΡ‡ΠΈΡ‚ΡŒ", + "cos": "ΠšΠΎΡΠΈΠ½ΡƒΡ", + "distance": "РасстояниС", + "divide": "Π Π°Π·Π΄Π΅Π»ΠΈΡ‚ΡŒ", + "entity-attribute": "Атрибут", + "entity-health": "Π—Π΄ΠΎΡ€ΠΎΠ²ΡŒΠ΅", + "entity-position": "ΠŸΠΎΠ·ΠΈΡ†ΠΈΡ", + "entity-rotation": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚ ΠΈ Π΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅", + "entity-state": "БостояниС сущности", + "equal": "Π Π°Π²Π½ΠΎ", + "find-player": "Π˜Π³Ρ€ΠΎΠΊ ΠΏΠΎ ΠΈΠΌΠ΅Π½ΠΈ", + "floor": "ΠžΠΊΡ€ΡƒΠ³Π»ΠΈΡ‚ΡŒ Π²Π½ΠΈΠ·", + "format-text": "Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ тСкст", + "format-text.format.description": "ВСкст с мСстами ΠΏΠΎΠ΄ значСния: %s - Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅, %.2f - число с двумя Π·Π½Π°ΠΊΠ°ΠΌΠΈ. %1$s - Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ a Π² любом мСстС.", + "greater": "Π‘ΠΎΠ»ΡŒΡˆΠ΅", + "greater-equal": "Π‘ΠΎΠ»ΡŒΡˆΠ΅ ΠΈΠ»ΠΈ Ρ€Π°Π²Π½ΠΎ", + "held-item": "ΠŸΡ€Π΅Π΄ΠΌΠ΅Ρ‚ Π² Ρ€ΡƒΠΊΠ΅", + "is-null": "ΠŸΡƒΡΡ‚ΠΎ", + "join-text": "Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒ тСкст", + "lerp": "Π‘ΠΌΠ΅ΡˆΠ°Ρ‚ΡŒ", + "less": "МСньшС", + "less-equal": "МСньшС ΠΈΠ»ΠΈ Ρ€Π°Π²Π½ΠΎ", + "list": "Бписок", + "list-add": "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π² список", + "list-contains": "Бписок содСрТит", + "list-get": "Π­Π»Π΅ΠΌΠ΅Π½Ρ‚ списка", + "list-remove": "Π£Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· списка", + "list-size": "Π Π°Π·ΠΌΠ΅Ρ€ списка", + "map-get": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ словаря", + "map-has": "Π•ΡΡ‚ΡŒ ΠΊΠ»ΡŽΡ‡", + "map-keys": "ΠšΠ»ΡŽΡ‡ΠΈ словаря", + "map-put": "Π—Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ", + "map-remove": "Π£Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· словаря", + "max": "ΠœΠ°ΠΊΡΠΈΠΌΡƒΠΌ", + "min": "ΠœΠΈΠ½ΠΈΠΌΡƒΠΌ", + "modulo": "ΠžΡΡ‚Π°Ρ‚ΠΎΠΊ", + "multiply": "Π£ΠΌΠ½ΠΎΠΆΠΈΡ‚ΡŒ", + "not": "НС", + "not-equal": "НС Ρ€Π°Π²Π½ΠΎ", + "or": "Или", + "pin.alive": "Π–ΠΈΠ²", + "pin.attribute": "Атрибут", + "pin.condition": "УсловиС", + "pin.count": "ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ", + "pin.day_time": "ВрСмя суток", + "pin.dimension": "Π˜Π·ΠΌΠ΅Ρ€Π΅Π½ΠΈΠ΅", + "pin.else": "Если Π½Π΅Ρ‚", + "pin.entity": "Π‘ΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ", + "pin.eyes": "Π“Π»Π°Π·Π°", + "pin.factor": "ΠœΠ½ΠΎΠΆΠΈΡ‚Π΅Π»ΡŒ", + "pin.food": "Π‘Ρ‹Ρ‚ΠΎΡΡ‚ΡŒ", + "pin.format": "Π¨Π°Π±Π»ΠΎΠ½", + "pin.from": "ΠžΡ‚", + "pin.game_mode": "Π Π΅ΠΆΠΈΠΌ ΠΈΠ³Ρ€Ρ‹", + "pin.health": "Π—Π΄ΠΎΡ€ΠΎΠ²ΡŒΠ΅", + "pin.held_item": "ΠŸΡ€Π΅Π΄ΠΌΠ΅Ρ‚ Π² Ρ€ΡƒΠΊΠ΅", + "pin.in_water": "Π’ Π²ΠΎΠ΄Π΅", + "pin.index": "ИндСкс", + "pin.item": "ΠŸΡ€Π΅Π΄ΠΌΠ΅Ρ‚", + "pin.key": "ΠšΠ»ΡŽΡ‡", + "pin.keys": "ΠšΠ»ΡŽΡ‡ΠΈ", + "pin.length": "Π”Π»ΠΈΠ½Π°", + "pin.level": "ΠœΠΈΡ€", + "pin.list": "Бписок", + "pin.look": "Взгляд", + "pin.map": "Π‘Π»ΠΎΠ²Π°Ρ€ΡŒ", + "pin.max": "Π”ΠΎ", + "pin.max_health": "Макс. Π·Π΄ΠΎΡ€ΠΎΠ²ΡŒΠ΅", + "pin.min": "ΠžΡ‚", + "pin.name": "Имя", + "pin.normalized": "НаправлСниС", + "pin.on_ground": "На Π·Π΅ΠΌΠ»Π΅", + "pin.part": "Π§Π°ΡΡ‚ΡŒ", + "pin.pitch": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚ (X)", + "pin.player": "Π˜Π³Ρ€ΠΎΠΊ", + "pin.position": "ΠŸΠΎΠ·ΠΈΡ†ΠΈΡ", + "pin.raining": "Π˜Π΄Ρ‘Ρ‚ доТдь", + "pin.result": "Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚", + "pin.size": "Π Π°Π·ΠΌΠ΅Ρ€", + "pin.sneaking": "ΠšΡ€Π°Π΄Ρ‘Ρ‚ΡΡ", + "pin.sprinting": "Π‘Π΅ΠΆΠΈΡ‚", + "pin.t": "Доля", + "pin.text": "ВСкст", + "pin.then": "Если Π΄Π°", + "pin.to": "Π”ΠΎ", + "pin.uuid": "UUID", + "pin.value": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅", + "pin.vector": "Π’Π΅ΠΊΡ‚ΠΎΡ€", + "pin.velocity": "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ", + "pin.yaw": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚ (Y)", + "player": "ΠšΠΎΠ½Ρ‚Π΅ΠΊΡΡ‚ истории", + "player-state": "БостояниС ΠΈΠ³Ρ€ΠΎΠΊΠ°", + "power": "Π‘Ρ‚Π΅ΠΏΠ΅Π½ΡŒ", + "random": "Π‘Π»ΡƒΡ‡Π°ΠΉΠ½ΠΎΠ΅", + "range": "Числа ΠΎΡ‚ ΠΈ Π΄ΠΎ", + "round": "ΠžΠΊΡ€ΡƒΠ³Π»ΠΈΡ‚ΡŒ", + "select": "Π’Ρ‹Π±ΠΎΡ€ ΠΏΠΎ ΡƒΡΠ»ΠΎΠ²ΠΈΡŽ", + "sin": "Бинус", + "sqrt": "ΠšΠΎΡ€Π΅Π½ΡŒ", + "subtract": "Π’Ρ‹Ρ‡Π΅ΡΡ‚ΡŒ", + "text-contains": "ВСкст содСрТит", + "text-length": "Π”Π»ΠΈΠ½Π° тСкста", + "text-replace": "Π—Π°ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π² тСкстС", + "to-number": "Π’ число", + "to-text": "Π’ тСкст", + "vector": "Π’Π΅ΠΊΡ‚ΠΎΡ€", + "vector-add": "Π‘Π»ΠΎΠΆΠΈΡ‚ΡŒ Π²Π΅ΠΊΡ‚ΠΎΡ€Ρ‹", + "vector-length": "Π”Π»ΠΈΠ½Π° Π²Π΅ΠΊΡ‚ΠΎΡ€Π°", + "vector-lerp": "Π‘ΠΌΠ΅ΡˆΠ°Ρ‚ΡŒ Π²Π΅ΠΊΡ‚ΠΎΡ€Ρ‹", + "vector-scale": "Π£ΠΌΠ½ΠΎΠΆΠΈΡ‚ΡŒ Π²Π΅ΠΊΡ‚ΠΎΡ€", + "vector-split": "Части Π²Π΅ΠΊΡ‚ΠΎΡ€Π°", + "vector-subtract": "Π’Ρ‹Ρ‡Π΅ΡΡ‚ΡŒ Π²Π΅ΠΊΡ‚ΠΎΡ€Ρ‹", + "xor": "Π ΠΎΠ²Π½ΠΎ ΠΎΠ΄Π½ΠΎ" +} diff --git a/addons/narrative/src/main/lang/ru_ru/hollowengine/story/param.json b/addons/narrative/src/main/lang/ru_ru/hollowengine/story/param.json new file mode 100644 index 000000000..79f40e6f7 --- /dev/null +++ b/addons/narrative/src/main/lang/ru_ru/hollowengine/story/param.json @@ -0,0 +1,67 @@ +{ + "animation": "Анимация", + "arrivalDistance": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒΡΡ Π·Π°", + "arrivalDistance.description": "Насколько Π±Π»ΠΈΠ·ΠΊΠΎ считаСтся Β«Π΄ΠΎΡˆΡ‘Π»Β», Π² Π±Π»ΠΎΠΊΠ°Ρ….", + "color": "Π¦Π²Π΅Ρ‚", + "color.description": "Π¦Π²Π΅Ρ‚ Π²ΠΈΠ΄Π° #000000.", + "duration": "Π”Π»ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ", + "duration.description": "Π’ миллисСкундах; ΠΌΠΎΠΆΠ½ΠΎ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ 2s ΠΈΠ»ΠΈ 1m.", + "effect": "Π­Ρ„Ρ„Π΅ΠΊΡ‚", + "entity": "Π‘ΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ", + "face": "Π‘Ρ‚ΠΎΡ€ΠΎΠ½Π°", + "face.description": "Π‘Ρ‚ΠΎΡ€ΠΎΠ½Π° Π±Π»ΠΎΠΊΠ°; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½Π°, Ρ‚ΠΎ Ρ‚Π°, ΠΊΡƒΠ΄Π° сСйчас смотрит NPC.", + "fade": "БмСшиваниС", + "fade.description": "Π—Π° сколько анимация ΠΏΠ»Π°Π²Π½ΠΎ Π²Ρ…ΠΎΠ΄ΠΈΡ‚ ΠΈΠ»ΠΈ Π²Ρ‹Ρ…ΠΎΠ΄ΠΈΡ‚.", + "fadeIn": "ПоявлСниС", + "fadeOut": "Π—Π°Ρ‚ΡƒΡ…Π°Π½ΠΈΠ΅", + "hand": "Π ΡƒΠΊΠ°", + "handle": "Π—Π°Π΄Π°Ρ‡Π°", + "id": "Id", + "immediate": "Π‘Ρ€Π°Π·Ρƒ", + "level": "Π˜Π·ΠΌΠ΅Ρ€Π΅Π½ΠΈΠ΅", + "level.description": "Π“Π΄Π΅ это происходит; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ΠΎ, Ρ‚ΠΎ ΠΈΠ·ΠΌΠ΅Ρ€Π΅Π½ΠΈΠ΅ ΠΏΠ΅Ρ€Π²ΠΎΠ³ΠΎ ΠΈΠ³Ρ€ΠΎΠΊΠ° сцСны.", + "material": "ΠœΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»", + "maxAngularSpeed": "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ ΠΏΠΎΠ²ΠΎΡ€ΠΎΡ‚Π°", + "maxAngularSpeed.description": "НС большС ΡΡ‚ΠΎΠ»ΡŒΠΊΠΈΡ… градусов Π² сСкунду.", + "model": "МодСль", + "montage": "ΠœΠΎΠ½Ρ‚Π°ΠΆ", + "name": "Имя", + "passDistance": "ΠŸΡ€ΠΎΠΉΡ‚ΠΈ Π² ΠΏΡ€Π΅Π΄Π΅Π»Π°Ρ…", + "passDistance.description": "Насколько Π±Π»ΠΈΠ·ΠΊΠΎ ΠΊ Ρ‚ΠΎΡ‡ΠΊΠ΅ Π½Π° ΠΏΡƒΡ‚ΠΈ считаСтся Β«ΠΏΡ€ΠΎΡˆΡ‘Π»Β», Π² Π±Π»ΠΎΠΊΠ°Ρ….", + "pitch": "Высота", + "player": "Π˜Π³Ρ€ΠΎΠΊ", + "points": "Π’ΠΎΡ‡ΠΊΠΈ", + "points.description": "Π’ΠΎΡ‡ΠΊΠΈ, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΏΡ€ΠΎΠΉΡ‚ΠΈ ΠΏΠΎ порядку, [[x, y, z], ...].", + "pos": "ΠŸΠΎΠ·ΠΈΡ†ΠΈΡ", + "pos.description": "Π’ΠΎΡ‡ΠΊΠ° Π² ΠΌΠΈΡ€Π΅, [x, y, z].", + "position": "ΠŸΠΎΠ·ΠΈΡ†ΠΈΡ", + "position.description": "Π’ΠΎΡ‡ΠΊΠ° Π² ΠΌΠΈΡ€Π΅, [x, y, z].", + "reach": "Π”ΠΎΡΡΠ³Π°Π΅ΠΌΠΎΡΡ‚ΡŒ", + "reach.description": "Как Π΄Π°Π»Π΅ΠΊΠΎ дотягиваСтся, Π² Π±Π»ΠΎΠΊΠ°Ρ….", + "repathIntervalTicks": "ΠŸΠ΅Ρ€Π΅ΡΡ‚Ρ€Π°ΠΈΠ²Π°Ρ‚ΡŒ ΠΏΡƒΡ‚ΡŒ ΠΊΠ°ΠΆΠ΄Ρ‹Π΅", + "repathIntervalTicks.description": "Π’ΠΈΠΊΠΎΠ² ΠΌΠ΅ΠΆΠ΄Ρƒ поисками Π½ΠΎΠ²ΠΎΠ³ΠΎ ΠΏΡƒΡ‚ΠΈ.", + "requireCorrectTool": "НуТСн подходящий инструмСнт", + "sound": "Π—Π²ΡƒΠΊ", + "source": "Π€Π°ΠΉΠ»", + "speaker": "Π“ΠΎΠ²ΠΎΡ€ΠΈΡ‚", + "speed": "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ", + "speed.description": "1 - обычная ΡΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ Ρ…ΠΎΠ΄ΡŒΠ±Ρ‹.", + "stuckTimeoutTicks": "Застрял Ρ‡Π΅Ρ€Π΅Π·", + "stuckTimeoutTicks.description": "Π’ΠΈΠΊΠΎΠ² Π±Π΅Π· продвиТСния, послС ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Ρ… NPC считаСтся Π·Π°ΡΡ‚Ρ€ΡΠ²ΡˆΠΈΠΌ.", + "target": "ЦСль", + "targetMoveThreshold": "Π”ΠΎΠ³ΠΎΠ½ΡΡ‚ΡŒ послС", + "targetMoveThreshold.description": "На сколько Π±Π»ΠΎΠΊΠΎΠ² Π΄ΠΎΠ»ΠΆΠ½Π° ΡΠΌΠ΅ΡΡ‚ΠΈΡ‚ΡŒΡΡ Ρ†Π΅Π»ΡŒ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡƒΡ‚ΡŒ искался Π·Π°Π½ΠΎΠ²ΠΎ.", + "text": "ВСкст", + "texture": "ВСкстура", + "time": "ВрСмя", + "time.description": "Π’ миллисСкундах; ΠΌΠΎΠΆΠ½ΠΎ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ 2s ΠΈΠ»ΠΈ 1m.", + "to": "ΠšΠΎΠΌΡƒ", + "to.description": "Π˜Π³Ρ€ΠΎΠΊΠΈ, для ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Ρ… это; Ссли Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½Ρ‹ - всС Π½Π° сцСнС.", + "unavailableTarget": "Если Ρ†Π΅Π»ΡŒ ΠΏΡ€ΠΎΠΏΠ°Π»Π°", + "unreachable": "Если Π½Π΅ Π΄ΠΎΠΉΡ‚ΠΈ", + "unreachableTimeoutTicks": "Π‘Π΄Π°Ρ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π·", + "unreachableTimeoutTicks.description": "Π’ΠΈΠΊΠΎΠ² нСдостиТимой Ρ†Π΅Π»ΠΈ, послС ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Ρ… NPC сдаётся.", + "volume": "Π“Ρ€ΠΎΠΌΠΊΠΎΡΡ‚ΡŒ", + "who": "ΠšΡ‚ΠΎ", + "who.description": "ΠŸΠ΅Ρ€ΡΠΎΠ½Π°ΠΆ ΠΈΠ»ΠΈ ΡΡƒΡ‰Π½ΠΎΡΡ‚ΡŒ, которая это Π΄Π΅Π»Π°Π΅Ρ‚." +} diff --git a/addons/narrative/src/main/lang/ru_ru/index.json b/addons/narrative/src/main/lang/ru_ru/index.json new file mode 100644 index 000000000..83179d9e9 --- /dev/null +++ b/addons/narrative/src/main/lang/ru_ru/index.json @@ -0,0 +1,22 @@ +{ + "hollowengine.commands.dialogue_no_such_option": "Π’ вашСм Π΄ΠΈΠ°Π»ΠΎΠ³Π΅ Π½Π΅Ρ‚ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π° %s.", + "hollowengine.commands.dialogue_not_in_dialogue": "БСйчас Π²Ρ‹ Π½Π΅ участвуСтС Π² Π΄ΠΈΠ°Π»ΠΎΠ³Π΅.", + "hollowengine.commands.story.checkpoint": "%s. %s (%s)", + "hollowengine.commands.story.entry": "%s: %s", + "hollowengine.commands.story.failed": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ ΠΎΡ‚ΠΊΠ°Π·Π°Π»Π°ΡΡŒ: %s", + "hollowengine.commands.story.missing": "Π—Π΄Π΅ΡΡŒ Π½Π΅Ρ‚ истории %s.", + "hollowengine.commands.story.none": "Π—Π΄Π΅ΡΡŒ Π½Π΅Ρ‚ историй.", + "hollowengine.commands.story.rewound": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ %s возвращаСтся ΠΊ Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Ρƒ %s.", + "hollowengine.commands.story.rewound_latest": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ %s возвращаСтся ΠΊ послСднСму ΠΏΡ€ΠΎΠΉΠ΄Π΅Π½Π½ΠΎΠΌΡƒ Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Ρƒ.", + "hollowengine.commands.story.started": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ %s Π·Π°ΠΏΡƒΡ‰Π΅Π½Π° со стадии %s.", + "hollowengine.commands.story.started_scene": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ %s Π·Π°ΠΏΡƒΡ‰Π΅Π½Π°: %s.", + "hollowengine.commands.story.stopped": "Π˜ΡΡ‚ΠΎΡ€ΠΈΡ %s остановлСна.", + "hollowengine.gui.history.story.add": "Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ", + "hollowengine.gui.history.story.duplicate": "Копия ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ", + "hollowengine.gui.history.story.edit": "ΠŸΡ€Π°Π²ΠΊΠ° ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ", + "hollowengine.gui.history.story.link": "ИзмСнСниС связи", + "hollowengine.gui.history.story.move": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ", + "hollowengine.gui.history.story.remove": "Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ", + "hollowengine.gui.history.timeline.move_cutscene": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅ катсцСны", + "hollowengine.gui.history.timeline.reanchor_cutscene": "ΠŸΠ΅Ρ€Π΅ΠΏΡ€ΠΈΠ²ΡΠ·ΠΊΠ° катсцСны" +} diff --git a/addons/narrative/src/main/resources/META-INF/plugin.properties b/addons/narrative/src/main/resources/META-INF/plugin.properties new file mode 100644 index 000000000..a9cf86e72 --- /dev/null +++ b/addons/narrative/src/main/resources/META-INF/plugin.properties @@ -0,0 +1,7 @@ +id=hollowengine-narrative +name=HollowEngine Narrative +version=${version} +entry=ru.hollowhorizon.hollowengine.addons.narrative.HollowNarrativeAddon +environment=common +description=Dialogues, story graphs with their editors, story functions for NPCs, and cutscenes +authors=HollowHorizon diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/button.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/button.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/button.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/button.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/character_name.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/character_name.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/character_name.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/character_name.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/cursor.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/cursor.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/cursor.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/cursor.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/desc.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/desc.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/desc.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/desc.png diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/dialogue_box.png b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/dialogue_box.png similarity index 100% rename from runtime/src/main/resources/assets/hollowengine/textures/gui/dialogues/dialogue_box.png rename to addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/dialogues/dialogue_box.png diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/block.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/block.svg new file mode 100644 index 000000000..2b446effc --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/block.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/broken.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/broken.svg new file mode 100644 index 000000000..4c7820fff --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/broken.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/data.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/data.svg new file mode 100644 index 000000000..f6f19e91b --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/data.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/navigation.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/navigation.svg new file mode 100644 index 000000000..fdaa513f4 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/navigation.svg @@ -0,0 +1,3 @@ + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/npc.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/npc.svg new file mode 100644 index 000000000..40c9676a6 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/npc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/time.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/time.svg new file mode 100644 index 000000000..da4eec4d8 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/category/time.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/choice.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/choice.svg new file mode 100644 index 000000000..f0f8ae9f9 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/choice.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/command.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/command.svg new file mode 100644 index 000000000..950aae27c --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/command.svg @@ -0,0 +1,3 @@ + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/condition.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/condition.svg new file mode 100644 index 000000000..acdca9b73 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/condition.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/label.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/label.svg new file mode 100644 index 000000000..a1c65373c --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/label.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/more.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/more.svg new file mode 100644 index 000000000..d07d507d2 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/phrase.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/phrase.svg new file mode 100644 index 000000000..25ef9a0fe --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/phrase.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/wait.svg b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/wait.svg new file mode 100644 index 000000000..124bb52d3 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/textures/gui/icons/story/wait.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/dialogue-overlay.hss b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/dialogue-overlay.hss new file mode 100644 index 000000000..2d1f9ac05 --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/dialogue-overlay.hss @@ -0,0 +1,170 @@ +#speech-root { + size: fill fill; +} + +#speech { + size: fit fit; + width: 56%; + max-width: 320px; + align: center end; + margin: 0px 0px 54px 0px; + padding: 6px 10px 7px 10px; + gap: 3px; + align-items: start start; + border-radius: 4px; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.38), rgba(0, 0, 0, 0.52)); + backdrop-filter: blur(32px); + box-shadow: + -6px 0px 10px rgba(0, 0, 0, 0.22), + 6px 0px 10px rgba(0, 0, 0, 0.22), + inset 0px 1px 0px 0px rgba(255, 255, 255, 0.18), + inset 0px -1px 0px 0px rgba(0, 0, 0, 0.35); + transitions: background 90ms ease-out; + animations: speech-in 220ms ease-out both; +} + +#speech:hover { + background: linear-gradient(to bottom, rgba(20, 20, 20, 0.45), rgba(10, 10, 10, 0.58)); +} + +#speech-root:closing #speech { + animations: speech-out 420ms ease-in both; +} + +@keyframes speech-in { + from { opacity: 0; translate: 0 4 0; } + to { opacity: 1; translate: 0 0 0; } +} + +@keyframes speech-out { + from { opacity: 1; } + to { opacity: 0; } +} + +#speech-name { + color: #C8C8C8; + text-effects: bold, shadow(0, 1, 2, #00000099); +} + +#speech-text { + size: fill fit; + color: #FFFFFF; + line-spacing: 2px; + text-wrap: true; + text-effects: shadow(0, 1, 2, #00000099); +} + +.narration #speech-text { + color: #D8D8D8; + text-effects: italic, shadow(0, 1, 2, #00000099); +} + +.speech-pending { + opacity: 0; +} + +#speech-next { + color: #AAAAAA; + animations: next-pulse 1100ms ease-in-out infinite; +} + +@keyframes next-pulse { + 0% { opacity: 0.35; } + 50% { opacity: 1; } + 100% { opacity: 0.35; } +} + +#choice-root { + size: fill fill; +} + +#choice { + size: fit fit; + min-width: 90px; + max-width: 260px; + align: center end; + margin: 0px 0px 96px 0px; + gap: 2px; + align-items: stretch start; + animations: choice-in 200ms ease-out both; +} + +#choice.docked { + align: start start; + margin: 0px; +} + +#choice-root:closing #choice { + animations: choice-out 420ms ease-in both; + animation-delay: 120ms; +} + +@keyframes choice-in { + from { opacity: 0; translate: 0 4 0; } + to { opacity: 1; translate: 0 0 0; } +} + +@keyframes choice-out { + from { opacity: 1; } + to { opacity: 0; } +} + +.choice-option { + size: auto fit; + padding: 4px 10px 4px 6px; + gap: 6px; + align-items: start start; + border-radius: 3px; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.38), rgba(0, 0, 0, 0.52)); + backdrop-filter: blur(32px); + box-shadow: + inset 0px 1px 0px 0px rgba(255, 255, 255, 0.12), + inset 0px -1px 0px 0px rgba(0, 0, 0, 0.3); + transitions: + background 90ms ease-out, + opacity 160ms ease-out; +} + +.choice-number { + color: #6F6F6F; + text-effects: shadow(0, 1, 2, #00000099); +} + +.choice-text { + size: fit fit; + color: #C8C8C8; + text-wrap: true; + text-effects: shadow(0, 1, 2, #00000099); +} + +.choice-option:highlighted, +.choice-option:hover, +.choice-option:voted, +.choice-option:won { + background: linear-gradient(to bottom, rgba(80, 80, 80, 0.55), rgba(60, 60, 60, 0.65)); +} + +.choice-option:highlighted .choice-number, +.choice-option:hover .choice-number, +.choice-option:voted .choice-number, +.choice-option:won .choice-number, +.choice-option:highlighted .choice-text, +.choice-option:hover .choice-text, +.choice-option:voted .choice-text, +.choice-option:won .choice-text { + color: #FFFFFF; +} + +.choice-option:active { + background: linear-gradient(to bottom, rgba(100, 100, 100, 0.6), rgba(80, 80, 80, 0.7)); +} + +.choice-option:lost { + opacity: 0.3; +} + +#choice-votes { + align: end start; + color: #888888; + text-effects: shadow(0, 1, 2, #00000099); +} diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/story-editor.hss b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/story-editor.hss new file mode 100644 index 000000000..5a162f79b --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/story-editor.hss @@ -0,0 +1,146 @@ +@import "hollowengine:ui/styles/theme.hss"; + +.story-editor-root { + padding: 0px; +} + +.story-muted { + height: fit; + foreground: $text-muted; + font-size: 8px; + text-wrap: false; +} + +.story-muted.wrapped { + size: 100% fit; + text-wrap: true; +} + +.story-field.error { + border-color: $danger; +} + +.story-kotlin { + padding: 6px 0px; + font-family: $font-code; + background: $bg-field; + border-radius: 4px; +} + +.story-inspector-problem { + size: 100% fit; + foreground: $danger; + font-size: 8px; + text-wrap: true; +} + +.story-inspector-problem.warning { + foreground: $warning; +} + +.story-code-dialog { + padding: 0px; +} + +.story-code-frame { + background: $bg-panel; + border-radius: $radius-large; +} + +.story-code-head { + size: 100% 26px; + padding: 0px 6px 0px 10px; + gap: 8px; + align-items: center; + border-bottom: 1px $divider; +} + +.story-code-title { + foreground: $text-strong; + font-size: 9px; +} + +.story-code-type { + foreground: $text-muted; + font-family: $font-code; + font-size: 8px; +} + +.story-code-body { + padding: 6px 0px; + background: $bg-field; +} + +.story-card { + size: 100% fit; + background: $bg-field; + border-radius: $radius; + margin-bottom: 4px; +} + +.story-card-head { + size: 100% 22px; + padding: 0px 3px 0px 5px; + gap: 5px; + align-items: center; + background: $bg-raised; + border-radius: $radius; + transition: background 110ms ease-out; +} + +.story-card-head:hover { + background: $bg-hover; +} + +.story-card-head .insp-arrow { + size: 8px 8px; + margin: 0px; +} + +.story-card-title { + foreground: $text-strong; + font-family: $font-code; + font-size: 9px; + text-wrap: false; +} + +.story-card-type { + foreground: $text-muted; + font-family: $font-code; + font-size: 8px; + text-wrap: false; +} + +.story-card-body { + size: 100% fit; + padding: 2px 8px 8px 8px; + gap: 4px; +} + +.st-color { + size: 16px 16px; + margin-left: 4px; + border: 1px $border-strong; + border-radius: $radius-small; +} + +.st-color:hover { + border: 1px $white; +} + +.st-picks { + size: 16px 16px; + margin-left: 4px; + align-items: center; + justify-content: center; + border-radius: $radius-small; +} + +.st-picks:hover { + background: $bg-hover; +} + +.st-picks-arrow { + size: 7px 5px; + transform: rotate(180deg); +} diff --git a/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/story-graph.hss b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/story-graph.hss new file mode 100644 index 000000000..33e038aba --- /dev/null +++ b/addons/narrative/src/main/resources/assets/hollowengine-narrative/ui/styles/story-graph.hss @@ -0,0 +1,65 @@ +@import "hollowengine:ui/styles/theme.hss"; + +.gn-pin.gn-type-flow { border-color: #E6E8EE; border-radius: 2px; } +.gn-pin.gn-type-text { border-color: #E89AC0; } +.gn-pin.gn-type-number { border-color: #44BC74; } +.gn-pin.gn-type-bool { border-color: #D9605A; } +.gn-pin.gn-type-vector { border-color: #F0CF60; } +.gn-pin.gn-type-entity { border-color: #4FA8E0; } +.gn-pin.gn-type-npc { border-color: #4FD0D0; } +.gn-pin.gn-type-player { border-color: #6F8CF0; } +.gn-pin.gn-type-list { border-color: #C9874A; } +.gn-pin.gn-type-map { border-color: #B48EE0; } +.gn-pin.gn-type-object { border-color: #8FA0B8; } +.gn-pin.gn-type-any { border-color: #A5ABC0; } + +.gn-pin.connected.gn-type-flow { background: #E6E8EE; } +.gn-pin.connected.gn-type-text { background: #E89AC0; } +.gn-pin.connected.gn-type-number { background: #44BC74; } +.gn-pin.connected.gn-type-bool { background: #D9605A; } +.gn-pin.connected.gn-type-vector { background: #F0CF60; } +.gn-pin.connected.gn-type-entity { background: #4FA8E0; } +.gn-pin.connected.gn-type-npc { background: #4FD0D0; } +.gn-pin.connected.gn-type-player { background: #6F8CF0; } +.gn-pin.connected.gn-type-list { background: #C9874A; } +.gn-pin.connected.gn-type-map { background: #B48EE0; } +.gn-pin.connected.gn-type-object { background: #8FA0B8; } +.gn-pin.connected.gn-type-any { background: #A5ABC0; } + +.graph-node.gn-reroute.gn-type-flow { background: #E6E8EE; border-radius: 2px; } + +.gn-pin-label.optional { + foreground: $text-faint; +} + +.gn-field.error, .insp-input.error { + border-color: $danger; +} + +.gn-field.warning { + border-color: $warning; +} + +.st-vector { + gap: 3px; +} + +.st-vector-menu { + size: 10px 10px; +} + +.st-row-button { + size: 12px 12px; + cursor: hand; + opacity: 0.6; + transition: opacity 110ms ease-out; +} + +.st-row-button:hover { + opacity: 1; +} + +.st-row-add { + gap: 4px; + cursor: hand; +} diff --git a/addons/narrative/src/main/resources/internal/templates/dialogue.story.template b/addons/narrative/src/main/resources/internal/templates/dialogue.story.template new file mode 100644 index 000000000..d13b3a50c --- /dev/null +++ b/addons/narrative/src/main/resources/internal/templates/dialogue.story.template @@ -0,0 +1,24 @@ +{ + "version": 1, + "nodes": [ + { + "kind": "say", + "id": "hello", + "speaker": "Guide", + "text": "Hello, {player}!" + }, + { + "kind": "start", + "id": "main", + "name": "main" + } + ], + "links": [ + { + "from": "main", + "fromPin": "next", + "to": "hello", + "toPin": "in" + } + ] +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCurveTest.kt similarity index 99% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCurveTest.kt index 9a08da49e..97ace56ec 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneCurveTest.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import org.junit.jupiter.api.Test import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrationTest.kt similarity index 96% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrationTest.kt index cf68b235e..cce0af95c 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneMigrationTest.kt @@ -1,8 +1,8 @@ -package ru.hollowhorizon.hollowengine.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import org.junit.jupiter.api.Test import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.Keyframe -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.* +import ru.hollowhorizon.hollowengine.addons.narrative.cutscene.* import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneTest.kt similarity index 92% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneTest.kt index 2d7eac8c5..2d24c5577 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/cutscene/CutsceneTest.kt @@ -1,9 +1,7 @@ -package ru.hollowhorizon.hollowengine.cutscene +package ru.hollowhorizon.hollowengine.addons.narrative.cutscene import org.junit.jupiter.api.Test import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.timelineRows import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -300,24 +298,6 @@ class CutsceneTest { assertEquals(null, playback.currentEnvironment.weather) } - @Test - fun `time of day formatter follows the minecraft clock`() { - assertEquals("06:00", TimeOfDayValueFormatter.format(0f)) - assertEquals("12:00", TimeOfDayValueFormatter.format(6_000f)) - assertEquals("00:00", TimeOfDayValueFormatter.format(18_000f)) - assertEquals("05:59", TimeOfDayValueFormatter.format(23_999f)) - assertEquals("07:00", TimeOfDayValueFormatter.format(25_000f)) - } - - @Test - fun `discrete tracks are omitted from curve editor rows`() { - val playback = CutscenePlaybackController() - val rows = timelineRows(playback.timeline, curveEditorOnly = true) - - assertTrue(rows.any { it.property === playback.timeOfDay }) - assertFalse(rows.any { it.property === playback.weather }) - } - private fun floatCurve(controller: TimelineController): ChannelCurve { val property = controller.addProperty( listOf("Test"), diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModelsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModelsTest.kt new file mode 100644 index 000000000..2977c6633 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryEntityModelsTest.kt @@ -0,0 +1,71 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlin.reflect.typeOf +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoryEntityModelsTest { + private val text = typeOf() + private val spawn = StoryFunctionSignature( + "spawn", "spawn", listOf( + StoryParam("id", text, optional = true, meaning = StoryParamMeaning.NPC_ID), + StoryParam("model", text, optional = true, meaning = StoryParamMeaning.MODEL), + ), + ) + private val play = StoryFunctionSignature( + "play", "play", listOf( + StoryParam("who", receiver = true), + StoryParam("animation", text, meaning = StoryParamMeaning.ANIMATION), + ), + ) + private val signatures = mapOf("spawn" to spawn, "play" to play) + + private fun spawn(id: String, vararg arguments: Pair) = + StoryNode.Call(id, action = "spawn", arguments = arguments.toMap()) + + private fun play(id: String) = StoryNode.Call(id, action = "play", arguments = mapOf("animation" to "\"wave\"")) + + private fun into(from: String, to: String, pin: String = "who") = StoryLink(from, StoryPins.VALUE, to, pin) + + private fun models(nodes: List, links: List) = + StoryEntityModels.of(StoryGraph(nodes = nodes, links = links)) { signatures[it.action] } + + @Test + fun `the model written where the NPC is spawned reaches the call through a reroute`() { + val found = models( + listOf(spawn("npc", "model" to "\"hollowengine:models/npc.glb\""), StoryNode.Reroute("dot", false), play("wave")), + listOf(into("npc", "dot", StoryPins.VALUE), into("dot", "wave")), + ) + assertEquals(mapOf("wave" to StoryEntityModel.Path("hollowengine:models/npc.glb")), found) + } + + @Test + fun `without a model the id of the NPC is asked of the world`() { + val found = models(listOf(spawn("npc", "id" to "\"village:smith\""), play("wave")), listOf(into("npc", "wave"))) + assertEquals(mapOf("wave" to StoryEntityModel.Npc("village:smith")), found) + } + + @Test + fun `a model given by Kotlin says nothing`() { + val found = models(listOf(spawn("npc", "model" to "pickModel()"), play("wave")), listOf(into("npc", "wave"))) + assertEquals(emptyMap(), found) + } + + @Test + fun `a variable passes on the model of what is written into it, unless the writes disagree`() { + val nodes = listOf( + spawn("a", "model" to "\"a.glb\""), + StoryNode.Assign("set-a", "hero"), + StoryNode.Expression("read", "hero"), + play("wave"), + ) + val links = listOf(into("a", "set-a", StoryPins.VALUE), into("read", "wave")) + assertEquals(mapOf("wave" to StoryEntityModel.Path("a.glb")), models(nodes, links)) + + val disagreeing = models( + nodes + spawn("b", "model" to "\"b.glb\"") + StoryNode.Assign("set-b", "hero"), + links + into("b", "set-b", StoryPins.VALUE), + ) + assertEquals(emptyMap(), disagreeing) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphEditsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphEditsTest.kt new file mode 100644 index 000000000..b5879b140 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryGraphEditsTest.kt @@ -0,0 +1,29 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import ru.hollowhorizon.hollowengine.addons.narrative.graph.StoryGraphEdits.copyNodes +import kotlin.test.Test +import kotlin.test.assertTrue + +class StoryGraphEditsTest { + @Test + fun `duplicating a start gives the copy a distinct stage name`() { + val graph = StoryGraph(nodes = listOf(StoryNode.Start("start", "main"))) + + val (copied, _) = graph.copyNodes(setOf("start"), StoryPoint(24f, 24f)) + val generated = StoryGraphCompiler.compile(copied) + + assertTrue( + generated.problems.none { it.key == "shared_start" }, + "An editor duplicate must preserve compilable stage names" + ) + } + + @Test + fun `duplicating a collapsed node keeps the copy collapsed`() { + val graph = StoryGraph(nodes = listOf(StoryNode.Say("say")), collapsed = setOf("say")) + + val (copied, ids) = graph.copyNodes(setOf("say"), StoryPoint(24f, 24f)) + + assertTrue(ids.getValue("say") in copied.collapsed) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryLiteralsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryLiteralsTest.kt new file mode 100644 index 000000000..d421958a9 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryLiteralsTest.kt @@ -0,0 +1,47 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StoryLiteralsTest { + /** A field writes the file on every letter, so what was typed has to come back letter for letter. */ + @Test + fun `a number survives being typed`() { + fun typed(text: String, type: KType) = StoryLiterals.text(StoryLiterals.expression(text, type), type) + listOf("-", "-1", "1.", "1.5", "x + 1").forEach { text -> + assertEquals(text, typed(text, typeOf())) + assertEquals(text, typed(text, typeOf())) + } + } + + @Test + fun `a whole number is still a double in Kotlin`() { + assertEquals("1.toDouble()", StoryLiterals.expression("1", typeOf())) + } + + @Test + fun `an expression is not taken for coordinates`() { + assertNull(StoryLiterals.vectorParts("storyPlayer().position()", "vec")) + } + + @Test + fun `a line takes its text from a link`() { + val graph = StoryGraph( + nodes = listOf(StoryNode.Start("s", "main"), StoryNode.Say("say", text = "typed"), StoryNode.Expression("value", "name")), + links = listOf(StoryLink("s", StoryPins.NEXT, "say", StoryPins.IN), StoryLink("value", StoryPins.VALUE, "say", StoryPins.TEXT)), + ) + val code = StoryGraphCompiler.compile(graph).code + assertTrue("((name)).toString())" in code, code) + } + + @Test + fun `an entity fits what takes any entity and nothing narrower`() { + assertTrue(StoryPinType.NPC.fits(StoryPinType.ENTITY)) + assertTrue(!StoryPinType.ENTITY.fits(StoryPinType.NPC)) + assertTrue(!StoryPinType.PLAYER.fits(StoryPinType.NPC)) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryTemplateTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryTemplateTest.kt new file mode 100644 index 000000000..91b4b8f49 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryTemplateTest.kt @@ -0,0 +1,15 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoryTemplateTest { + @Test + fun `the template of a new dialogue is a story that starts at main`() { + val text = javaClass.classLoader.getResourceAsStream("internal/templates/dialogue.story.template")!!.reader().readText() + val graph = StoryGraph.decode(text) + val start = graph.nodes.filterIsInstance().single() + assertEquals("main", start.name) + assertEquals(listOf("hello"), graph.links.filter { it.from == start.id && it.isFlow }.map { it.to }) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryValueFlowTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryValueFlowTest.kt new file mode 100644 index 000000000..31c6d8fca --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/graph/StoryValueFlowTest.kt @@ -0,0 +1,102 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.graph + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StoryValueFlowTest { + private val start = StoryNode.Start("s", "main") + private val loop = StoryNode.Loop("loop", "listOf(1)") + private val itemIntoSay = StoryLink("loop", StoryPins.ITEM, "say", StoryPins.TEXT) + + private fun flow(from: String, to: String, pin: String = StoryPins.NEXT) = StoryLink(from, pin, to, StoryPins.IN) + + private fun problems(nodes: List, links: List) = + StoryGraphCompiler.compile(StoryGraph(nodes = nodes, links = links)).problems + + @Test + fun `the body of a loop reads its item past a branch`() { + val found = problems( + listOf(start, loop, StoryNode.Branch("branch", "true"), StoryNode.Say("say")), + listOf( + flow("s", "loop"), flow("loop", "branch", StoryPins.BODY), flow("branch", "say", StoryPins.THEN), itemIntoSay, + ), + ) + assertEquals(emptyList(), found) + } + + /** The node gets a stage of its own, and the `for` the item lives in stays in the stage that calls it. */ + @Test + fun `a node reached from the body and after the loop cannot read the item`() { + val found = problems( + listOf(start, loop, StoryNode.Say("say")), + listOf(flow("s", "loop"), flow("loop", "say", StoryPins.BODY), flow("loop", "say"), itemIntoSay), + ) + assertEquals(listOf("loop_item_merged"), found.map { it.key }) + assertFalse(found.single().warning) + } + + @Test + fun `what follows a merge inside the body cannot read the item`() { + val found = problems( + listOf(start, loop, StoryNode.Branch("branch", "true"), StoryNode.Say("merge"), StoryNode.Say("say")), + listOf( + flow("s", "loop"), + flow("loop", "branch", StoryPins.BODY), + flow("branch", "merge", StoryPins.THEN), + flow("branch", "merge", StoryPins.ELSE), + flow("merge", "say"), + itemIntoSay, + ), + ) + assertEquals(listOf("say" to "loop_item_merged"), found.map { it.node to it.key }) + } + + @Test + fun `nothing after a loop reads its item`() { + val found = problems( + listOf(start, loop, StoryNode.Say("say")), + listOf(flow("s", "loop"), flow("loop", "say"), itemIntoSay), + ) + assertEquals(listOf("loop_item"), found.map { it.key }) + } + + private fun readAfterBranch(output: String): StoryGraphProblem { + val found = problems( + listOf( + start, + StoryNode.Branch("branch", "true"), + StoryNode.Code("code", "x = 1", outputs = mapOf("x" to output)), + StoryNode.Say("say"), + ), + listOf( + flow("s", "branch"), + flow("branch", "code", StoryPins.THEN), + flow("branch", "say", StoryPins.ELSE), + flow("code", "say"), + StoryLink("code", "x", "say", StoryPins.TEXT), + ), + ) + return found.single { it.key == "other_branch" } + } + + @Test + fun `a result a branch may skip stops the story unless it can be null`() { + assertFalse(readAfterBranch("Int").warning) + assertTrue(readAfterBranch("Int?").warning) + } + + @Test + fun `the block of a call does not read what the call returns`() { + val found = problems( + listOf(start, StoryNode.Call("call", "run", returns = "Int", block = true), StoryNode.Say("say")), + listOf( + flow("s", "call"), + flow("call", "say", StoryPins.BODY), + StoryLink("call", StoryPins.VALUE, "say", StoryPins.TEXT), + ), + ) + assertTrue(found.any { it.key == "other_branch" && it.node == "say" && !it.warning }, "$found") + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/scene/NarrativeActions.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/scene/NarrativeActions.kt new file mode 100644 index 000000000..3635e92f1 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/scene/NarrativeActions.kt @@ -0,0 +1,19 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.scene + +import ru.hollowhorizon.hollowengine.addons.narrative.HollowNarrativeAddon +import ru.hollowhorizon.hollowengine.addons.narrative.story.StoryActions +import java.io.File + +/** + * The actions of the narrative addon in tests, which load no addons: found in its classes by the same scan that + * registers them when the addon loads in the game. + */ +object NarrativeActions { + const val NAMESPACE = "hollowengine-narrative" + + fun register() { + val type = HollowNarrativeAddon::class.java + val classes = File(type.protectionDomain.codeSource.location.toURI()) + StoryActions.registerFound(classes, type.classLoader, type.packageName, NAMESPACE) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/speech/StoryTextTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/speech/StoryTextTest.kt new file mode 100644 index 000000000..8a7cfff0f --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/speech/StoryTextTest.kt @@ -0,0 +1,25 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.speech + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StoryTextTest { + private val keys = mapOf("mod.note" to "записка", "mod.line" to "Вся Ρ€Π΅ΠΏΠ»ΠΈΠΊΠ°").let { it::get } + + @Test + fun `a whole line and inserts are translated, the rest stays as written`() { + assertEquals("Вся Ρ€Π΅ΠΏΠ»ΠΈΠΊΠ°", StoryText.localized("mod.line", keys)) + assertEquals("ВСкст: записка, {mod.unknown}.", StoryText.localized("ВСкст: {mod.note}, {mod.unknown}.", keys)) + assertEquals("ВСкст с Ρ‚ΠΎΡ‡ΠΊΠΎΠΉ. И {name}", StoryText.localized("ВСкст с Ρ‚ΠΎΡ‡ΠΊΠΎΠΉ. И {name}", keys)) + } + + @Test + fun `plain sentences do not count as keys`() { + assertTrue(StoryText.hasKeys("mod.line")) + assertTrue(StoryText.hasKeys("ВСкст {mod.note}")) + assertFalse(StoryText.hasKeys("ΠšΠΎΠ½Π΅Ρ†.")) + assertFalse(StoryText.hasKeys("Hello there. Bye")) + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWaitTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/PersistentWaitTest.kt similarity index 96% rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWaitTest.kt rename to addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/PersistentWaitTest.kt index 6d050fba9..bafd55973 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWaitTest.kt +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/PersistentWaitTest.kt @@ -1,4 +1,4 @@ -package ru.hollowhorizon.hollowengine.common.scripting.state +package ru.hollowhorizon.hollowengine.addons.narrative.story import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt new file mode 100644 index 000000000..e2a9f8e5a --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryActionsTest.kt @@ -0,0 +1,29 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import ru.hollowhorizon.hollowengine.addons.narrative.scene.NarrativeActions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The actions narrative declares register with parameters the journal can keep, or marks as unsaved. */ +class StoryActionsTest { + @Test + fun `npc actions keep their receiver and targets and leave the options to their defaults`() { + NarrativeActions.register() + val move = StoryActions.find("${NarrativeActions.NAMESPACE}:move-to")!! + assertTrue(move.reversible) + assertEquals(listOf("this", "pos", "options"), move.parameterNames) + listOf("move-to-entity", "move-through", "avoid", "look-at", "look-at-entity").forEach { id -> + assertTrue(StoryActions.find("${NarrativeActions.NAMESPACE}:$id")?.reversible == true, "$id is undoable") + } + assertFalse(StoryActions.find("${NarrativeActions.NAMESPACE}:stop-moving")!!.reversible) + } + + @Test + fun `checkpoint labels register as actions of their own`() { + NarrativeActions.register() + assertEquals(listOf("this", "entity"), StoryActions.find("${NarrativeActions.NAMESPACE}:position")!!.parameterNames) + assertEquals(listOf("this", "player"), StoryActions.find("${NarrativeActions.NAMESPACE}:inventory")!!.parameterNames) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt new file mode 100644 index 000000000..645e8e2a4 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryFrameTest.kt @@ -0,0 +1,116 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.IntTag +import net.minecraft.nbt.ListTag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** How a saved frame finds its way back, without a compiler: the positions the generated code asks about. */ +class StoryFrameTest { + private object Owner : StoryOwner { + override val scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + override val server: MinecraftServer get() = error("no server") + override val entity: Entity? get() = null + } + + private val sites = arrayOf("intro#1", "if#1.then/trial#1", "gate") + + private fun saved(position: String?, configure: CompoundTag.() -> Unit = {}): StoryRun { + val frame = CompoundTag().apply { + putString("instance", "quests/village.kts") + putString("stage", "prologue") + putInt("hash", 7) + position?.let { putString("position", it) } + put("args", CompoundTag().apply { put("times", IntTag.valueOf(2)) }) + put("locals", CompoundTag().apply { put("map", IntTag.valueOf(5)) }) + configure() + } + val tag = CompoundTag().apply { put("frames", ListTag().apply { add(frame) }) } + return StoryRun.load(StoryRuns(Owner), "quest", null, tag) + } + + @Test + fun `a frame saved inside a call resumes into that call only`() { + val run = saved("call:if#1.then/trial#1") + val frame = run.pending.removeFirst().adopt(7, sites) + assertTrue(frame.isResuming()) + assertTrue(frame.isResumingAt(2)) + assertTrue(frame.enters(2, 3)) + assertFalse(frame.enters(0, 1), "code before the call is skipped") + assertTrue(frame.resumesInto(2, 5)) + assertEquals(5, StoryValues.decode(storySerializer(), frame.locals.get("map")!!)) + } + + @Test + fun `a frame saved after a checkpoint resumes after it`() { + val frame = saved("after:gate").pending.removeFirst().adopt(9, sites) + assertTrue(frame.isResumingAt(5)) + } + + @Test + fun `a frame whose position is gone starts over without its locals`() { + val frame = saved("after:removed#1").pending.removeFirst().adopt(7, sites) + assertFalse(frame.isResuming()) + assertTrue(frame.enters(0, 1)) + assertTrue(frame.locals.isEmpty) + } + + @Test + fun `a frame entered with other arguments starts over`() { + val frame = saved("after:gate").pending.removeFirst().adopt(7, sites) + frame.saveArg("times", storySerializer(), 2) + assertTrue(frame.isResuming(), "the same arguments keep the saved position") + frame.saveArg("times", storySerializer(), 3) + assertFalse(frame.isResuming()) + } + + @Test + fun `a run keeps its frames, branches and variables through a save`() { + val run = saved("after:gate") { + put("segment", CompoundTag().apply { putBoolean("done", true) }) + } + val tag = CompoundTag().apply { + put("frames", ListTag().apply { run.pending.forEach { add(it.save()) } }) + put("children", CompoundTag().apply { put("merchant", saved(null).save()) }) + put("vars", CompoundTag().apply { putInt("quests/village.kts/trust", 3) }) + } + val restored = StoryRun.load(StoryRuns(Owner), "quest", null, tag) + assertEquals("prologue@after:gate", restored.describe()) + assertEquals(setOf("merchant"), restored.children.keys) + assertEquals("quest/merchant", restored.children.getValue("merchant").path) + assertEquals(3, restored.vars.getInt("quests/village.kts/trust")) + assertEquals(restored.vars, restored.children.getValue("merchant").vars, "branches share the variables of their story") + assertTrue(restored.pending.single().segment.getBoolean("done")) + } + + @Test + fun `a saved tree is a snapshot independent of the working frames`() { + val run = saved("after:gate") + val frame = run.pending.single() + val snapshot = run.saveTree() + val expected = snapshot.copy() + frame.locals.putInt("map", 99) + frame.args.putInt("times", 99) + frame.segment.putBoolean("changed", true) + assertEquals(expected, snapshot) + } + + @Test + fun `restoring a tree does not let the run mutate the checkpoint`() { + val snapshot = saved("after:gate").saveTree() + val expected = snapshot.copy() + val run = StoryRun.load(StoryRuns(Owner), "quest", null, snapshot) + val frame = run.pending.single() + frame.locals.putInt("map", 99) + frame.args.putInt("times", 99) + frame.segment.putBoolean("changed", true) + assertEquals(expected, snapshot) + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt new file mode 100644 index 000000000..11c0a7efb --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryInstancesTest.kt @@ -0,0 +1,99 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.MinecraftServer +import net.minecraft.world.entity.Entity +import kotlin.test.* + +class StoryInstancesTest { + @StageScript("test/imported.kts") + class Imported + + @StageScript("test/root.kts") + class Root(val imported: Imported) + + @StageScript("test/construction.kts") + class StartsDuringConstruction(runs: StoryRuns) { + var observedReady: Boolean? = null + private set + + init { + runs.launch("construction", StageLambda0(StoryInstances.keyOf(this), "start", CompoundTag())) + } + + private val ready = true + + @StageEntry("start") + suspend fun start(args: CompoundTag): Any { + observedReady = ready + return Unit + } + } + + @AfterTest + fun clear() { + StoryWaits.clear() + StoryInstances.clear() + } + + @Test + fun `stopping a script unregisters the imported instances as well`() { + val scope = CoroutineScope(Dispatchers.Unconfined + Job()) + val root = Root(Imported()) + StoryInstances.register(root, scope) + val imported = StoryInstances.keyOf(root.imported) + assertSame(root.imported, StoryInstances.find(imported)) + scope.cancel() + assertNull(StoryInstances.find(imported)) + assertNull(StoryInstances.find("test/root.kts")) + } + + @Test + fun `a shared imported instance stays available until its last scope stops`() { + val first = CoroutineScope(Dispatchers.Unconfined + Job()) + val second = CoroutineScope(Dispatchers.Unconfined + Job()) + val imported = Imported() + StoryInstances.register(Root(imported), first) + StoryInstances.register(Root(imported), second) + val lifetime = StoryInstances.jobOf("test/imported.kts")!! + first.cancel() + assertSame(imported, StoryInstances.find("test/imported.kts")) + assertFalse(lifetime.isCancelled) + second.cancel() + assertNull(StoryInstances.find("test/imported.kts")) + assertTrue(lifetime.isCancelled) + } + + @Test + fun `stopping an old script does not unregister its replacement`() { + val oldScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val newScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val old = Root(Imported()) + val replacement = Root(Imported()) + StoryInstances.register(old, oldScope) + StoryInstances.register(replacement, newScope) + oldScope.cancel() + assertSame(replacement, StoryInstances.find("test/root.kts")) + assertSame(replacement.imported, StoryInstances.find("test/imported.kts")) + newScope.cancel() + } + + @Test + fun `a story launched by a script constructor waits until the script is fully initialized`() { + val scope = CoroutineScope(Dispatchers.Unconfined + Job()) + val owner = object : StoryOwner { + override val scope = scope + override val server: MinecraftServer get() = error("no server") + override val entity: Entity? get() = null + } + val script = StartsDuringConstruction(StoryRuns(owner)) + assertNull(script.observedReady) + StoryInstances.register(script, scope) + assertEquals(true, script.observedReady) + scope.cancel() + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt new file mode 100644 index 000000000..eb9165bad --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryLoopsTest.kt @@ -0,0 +1,19 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class StoryLoopsTest { + @Test + fun `progressions crossing the integer limits are counted without overflow`() { + assertEquals(3, StoryLoops.count(Int.MIN_VALUE..Int.MAX_VALUE step Int.MAX_VALUE)) + assertEquals(3, StoryLoops.count(Int.MAX_VALUE downTo Int.MIN_VALUE step Int.MAX_VALUE)) + assertEquals(0, StoryLoops.count(1..0)) + } + + @Test + fun `a progression too large for a saved index fails explicitly`() { + assertFailsWith { StoryLoops.count(Int.MIN_VALUE..Int.MAX_VALUE) } + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt new file mode 100644 index 000000000..19f63e42b --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryStoreTest.kt @@ -0,0 +1,34 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import net.minecraft.nbt.CompoundTag +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StoryStoreTest { + @TempDir + lateinit var directory: Path + + @Test + fun `a missing save has no stories`() { + assertNull(StoryStore(directory.resolve("stories.dat")).read()) + } + + @Test + fun `a damaged save recovers the previous complete snapshot`() { + val file = directory.resolve("stories.dat") + val store = StoryStore(file) + val first = CompoundTag().apply { putInt("version", 1) } + val second = CompoundTag().apply { putInt("version", 2) } + assertTrue(store.write(first)) + assertTrue(store.write(second)) + assertEquals(second, store.read()) + Files.write(file, byteArrayOf(10, 0)) + assertEquals(first, store.read()) + Files.list(directory).use { files -> assertTrue(files.noneMatch { it.fileName.toString().endsWith(".tmp") }) } + } +} diff --git a/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt new file mode 100644 index 000000000..eb9d191c8 --- /dev/null +++ b/addons/narrative/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/narrative/story/StoryValuesTest.kt @@ -0,0 +1,111 @@ +package ru.hollowhorizon.hollowengine.addons.narrative.story + +import kotlinx.serialization.Serializable +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.LongTag +import net.minecraft.nbt.StringTag +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.player.Player +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.common.npcs.navigation.Facing +import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveOptions +import ru.hollowhorizon.hollowengine.common.npcs.navigation.Zone +import ru.hollowhorizon.hollowengine.common.npcs.registry.NpcRef +import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class StoryValuesTest { + @Serializable + data class Loot(val name: String, val count: Int) + + @AfterTest + fun restoreResolver() { + StoryValues.entities = { _, _ -> null } + } + + private inline fun roundTrip(value: T): T { + val tag = CompoundTag() + StoryValues.put(tag, "value", storySerializer(), value) + @Suppress("UNCHECKED_CAST") + return StoryValues.get(tag, "value", storySerializer()) as T + } + + @Test + fun `a duration is kept as milliseconds`() { + val tag = CompoundTag() + StoryValues.put(tag, "value", storySerializer(), 1500.milliseconds) + + assertEquals(1500L, (tag.get("value") as LongTag).asLong) + assertEquals(1500.milliseconds, roundTrip(1500.milliseconds)) + } + + @Test + fun `move options come back with their facing`() { + val toward = MoveOptions(speed = 1.5, facing = Facing.Toward(UUID(1, 2))) + val point = MoveOptions(facing = Facing.at(Vec3(1.0, 2.0, 3.0)), avoid = listOf(Zone.Sphere(Vec3.ZERO, 2.0))) + + assertEquals(toward, roundTrip(toward)) + assertEquals(point, roundTrip(point)) + assertEquals(MoveOptions(), roundTrip(MoveOptions())) + } + + @Test + fun `plain values and collections of them come back as they were`() { + assertEquals(42, roundTrip(42)) + assertEquals("text", roundTrip("text")) + assertNull(roundTrip(null)) + assertEquals(listOf("a", "b"), roundTrip(listOf("a", "b"))) + assertEquals(mapOf("gold" to 3), roundTrip(mapOf("gold" to 3))) + assertEquals("a" to 1, roundTrip("a" to 1)) + assertEquals(Loot("sword", 1), roundTrip(Loot("sword", 1))) + } + + @Test + fun `NPC references and stage lambdas are saved by what identifies them`() { + val uuid = UUID.randomUUID() + assertEquals(uuid, roundTrip(NpcRef(uuid)).uuid) + + val lambda = StageLambda2("quests/village.kts", "script\$launchStory#1", CompoundTag().apply { putString("who", "alex") }) + val restored = roundTrip(lambda) + assertIs>(restored, "the number of parameters comes back with the lambda") + assertEquals(lambda.instance, restored.instance) + assertEquals(lambda.stage, restored.stage) + assertEquals(lambda.args, restored.args) + } + + @Test + fun `entities are kept as references, at any depth, and found again when read`() { + val uuid = UUID.randomUUID() + val asked = ArrayList>() + StoryValues.entities = { id, type -> + asked += id to type.simpleName!! + null + } + assertFailsWith { StoryValues.decode(storySerializer(), StringTag.valueOf(uuid.toString())) } + assertEquals(listOf(uuid to "Player"), asked) + + val listDescriptor = storySerializer>().descriptor + assertEquals("story.entity", listDescriptor.getElementDescriptor(0).serialName) + val mapDescriptor = storySerializer>().descriptor + assertEquals("story.entity", mapDescriptor.getElementDescriptor(1).serialName.removeSuffix("?")) + } + + @Test + fun `a value that cannot be saved is reported by its type`() { + val failure = assertFailsWith { storySerializer() } + assertEquals(true, failure.message?.contains("java.lang.Thread")) + } + + @Test + fun `zones come back equal to themselves, so an undone avoid finds the zone it added`() { + val zones = listOf(Zone.sphere(Vec3(1.0, 2.0, 3.0), 4.0, cost = 5f), Zone.box(Vec3(0.0, 0.0, 0.0), Vec3(2.0, 3.0, 4.0))) + assertEquals(zones, roundTrip(zones)) + } +} diff --git a/bootstrap-fabric/build.gradle.kts b/bootstrap-fabric/build.gradle.kts index 9bc5eaee0..2bb2f89c6 100644 --- a/bootstrap-fabric/build.gradle.kts +++ b/bootstrap-fabric/build.gradle.kts @@ -228,7 +228,7 @@ tasks.named("processResources") { } tasks.named("classes") { - dependsOn(embedRuntimeJar) + dependsOn(embedRuntimeJar, rootProject.tasks.named("buildAddons")) } tasks.named("shadowJar") { diff --git a/bootstrap-neoforge/build.gradle.kts b/bootstrap-neoforge/build.gradle.kts index ccfd81127..49a2a1951 100644 --- a/bootstrap-neoforge/build.gradle.kts +++ b/bootstrap-neoforge/build.gradle.kts @@ -219,7 +219,7 @@ tasks.named("processResources") { } tasks.named("classes") { - dependsOn(embedRuntimeJar) + dependsOn(embedRuntimeJar, rootProject.tasks.named("buildAddons")) } tasks.named("shadowJar") { diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java index 3dd512dd0..b982af4fd 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/impl/BootstrapRuntimeManager.java @@ -3,6 +3,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import ru.hollowhorizon.hollowengine.bootstrap.impl.transform.RuntimeClassTransformers; +import ru.hollowhorizon.hollowengine.bootstrap.runtime.AddonBootstrapContract; import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimeBridge; import ru.hollowhorizon.hollowengine.bootstrap.runtime.mixins.ScriptMixinProvider; @@ -28,7 +29,12 @@ private BootstrapRuntimeManager() { } private static List addonSources(File hollowEngineDirectory) { - return List.of(new File(hollowEngineDirectory, "addons"), new File("mods")); + List sources = new ArrayList<>(); + File development = AddonBootstrapContract.developmentAddonsDirectory(); + if (development != null) sources.add(development); + sources.add(new File(hollowEngineDirectory, "addons")); + sources.add(new File("mods")); + return sources; } private static String enginePackage() { diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/AbstractContainerScreenMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/AbstractContainerScreenMixin.java new file mode 100644 index 000000000..ea2a8a74c --- /dev/null +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/AbstractContainerScreenMixin.java @@ -0,0 +1,19 @@ +package ru.hollowhorizon.hollowengine.bootstrap.mixins.client; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import ru.hollowhorizon.hollowengine.bootstrap.impl.BootstrapRuntimeManager; + +/** A container draws the tooltip of its slot inside {@code render}, so what goes under tooltips is drawn before it. */ +@Mixin(AbstractContainerScreen.class) +public class AbstractContainerScreenMixin { + @Inject(method = "renderTooltip", at = @At("HEAD")) + private void hollowengine$beforeTooltip(GuiGraphics guiGraphics, int x, int y, CallbackInfo ci) { + BootstrapRuntimeManager.bridge().onContainerTooltipPre((Screen) (Object) this, guiGraphics); + } +} diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/ScreenMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/ScreenMixin.java index 0a996cfbb..37cbfed7d 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/ScreenMixin.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/ScreenMixin.java @@ -32,6 +32,16 @@ public class ScreenMixin { } } + @Inject(method = "renderWithTooltip", at = @At("HEAD")) + private void hollowengine$onFrameStart(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick, CallbackInfo ci) { + BootstrapRuntimeManager.bridge().onScreenFrameStart((Screen) (Object) this); + } + + @Inject(method = "renderBackground", at = @At("TAIL")) + private void hollowengine$onBackgroundRendered(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick, CallbackInfo ci) { + BootstrapRuntimeManager.bridge().onScreenBackgroundRendered((Screen) (Object) this, guiGraphics); + } + @Inject( method = "renderWithTooltip", at = @At( @@ -43,4 +53,9 @@ public class ScreenMixin { private void hollowengine$onRenderPost(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick, CallbackInfo ci) { BootstrapRuntimeManager.bridge().onScreenRenderPost((Screen) (Object) this, guiGraphics, mouseX, mouseY, partialTick); } + + @Inject(method = "renderWithTooltip", at = @At("TAIL")) + private void hollowengine$onFrameEnd(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick, CallbackInfo ci) { + BootstrapRuntimeManager.bridge().onScreenFrameEnd((Screen) (Object) this, guiGraphics); + } } diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/SoundEngineMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/SoundEngineMixin.java index dcd47ae72..17f7810aa 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/SoundEngineMixin.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/SoundEngineMixin.java @@ -21,7 +21,7 @@ public class SoundEngineMixin { @Shadow @Final private Map instanceToChannel; @Shadow @Final private SoundEngineExecutor executor; - @Inject(method = "updateSource", at = @At("TAIL")) + @Inject(method = "updateSource", at = @At("RETURN")) private void hollowengine$frame(Camera camera, CallbackInfo ci) { BootstrapRuntimeManager.bridge().onSoundFrame(instanceToChannel, executor); } diff --git a/bootstrap/src/main/resources/hollowengine.mixins.json b/bootstrap/src/main/resources/hollowengine.mixins.json index d295aa880..395011786 100644 --- a/bootstrap/src/main/resources/hollowengine.mixins.json +++ b/bootstrap/src/main/resources/hollowengine.mixins.json @@ -36,6 +36,7 @@ "client.ChannelAccessor", "LevelRendererMixin", "client.AbstractClientPlayerMixin", + "client.AbstractContainerScreenMixin", "client.DebugMainMixin", "client.EntityRenderDispatcherMixin", "client.EntityRendererMixin", diff --git a/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java b/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java index 9a2c602f5..9fbbfb6ee 100644 --- a/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java +++ b/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/AddonBootstrapContract.java @@ -1,5 +1,6 @@ package ru.hollowhorizon.hollowengine.bootstrap.runtime; +import java.io.File; import java.util.List; public final class AddonBootstrapContract { @@ -8,6 +9,8 @@ public final class AddonBootstrapContract { public static final String REGULAR_LIBRARY_PATH = "hollowengine-addon-libs/"; public static final String LOADED_ADDON_FINGERPRINTS_PROPERTY = "hollowengine.addons.bootstrap.loaded"; public static final String REJECTED_ADDON_FINGERPRINTS_PROPERTY = "hollowengine.addons.bootstrap.rejected"; + /** A directory of addon jars a development run loads ahead of every other source; set by the Gradle run tasks. */ + public static final String DEVELOPMENT_ADDONS_PROPERTY = "hollowengine.devAddons"; public static final List HOST_NATIVE_LIBRARY_PREFIXES = List.of( "lwjgl", "jemalloc", "glfw", "openal", "opengl", "stb", "tinyfd", "shaderc", "vulkan", "jinput", "jna-", "jna-platform-", "netty-", "netty-tcnative", "oshi-core" @@ -15,4 +18,11 @@ public final class AddonBootstrapContract { private AddonBootstrapContract() { } + + /** The directory named by {@link #DEVELOPMENT_ADDONS_PROPERTY}, or null outside a development run. */ + public static File developmentAddonsDirectory() { + String path = System.getProperty(DEVELOPMENT_ADDONS_PROPERTY); + if (path == null || path.isBlank()) return null; + return new File(path).getAbsoluteFile(); + } } diff --git a/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/RuntimeBridge.java b/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/RuntimeBridge.java index 5e3e34915..e19bb7e24 100644 --- a/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/RuntimeBridge.java +++ b/bridge/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/runtime/RuntimeBridge.java @@ -150,6 +150,18 @@ public interface RuntimeBridge extends AutoCloseable { void onScreenRenderPost(Screen screen, GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick); + /** Before the screen draws anything this frame. */ + void onScreenFrameStart(Screen screen); + + /** After the screen drew its background, before what it draws on it. */ + void onScreenBackgroundRendered(Screen screen, GuiGraphics guiGraphics); + + /** Before a container screen draws the tooltip of the slot under the cursor. */ + void onContainerTooltipPre(Screen screen, GuiGraphics guiGraphics); + + /** After the screen drew everything this frame, tooltips included. */ + void onScreenFrameEnd(Screen screen, GuiGraphics guiGraphics); + boolean onRenderArm(PoseStack stack, MultiBufferSource multiBufferSource, int packedLight, AbstractClientPlayer player, HumanoidArm arm); boolean onRenderItemInHand(Camera camera, float partialTick, Matrix4f projectionMatrix); diff --git a/build.gradle.kts b/build.gradle.kts index d9590bca1..63e74b9c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -102,6 +102,7 @@ fun Project.configureHollowAddon() { add("modCompileOnly", "net.fabricmc:fabric-loader:$fabricLoaderVersion") add("compileOnly", project(path = ":runtime", configuration = "namedElements")) add("testImplementation", project(path = ":runtime", configuration = "namedElements")) + add("testImplementation", project(path = ":bridge", configuration = "namedElements")) add("compileOnly", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") add("compileOnly", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") add("compileOnly", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") @@ -115,7 +116,7 @@ fun Project.configureHollowAddon() { tasks.named("jar") { archiveClassifier.set("classes-named") - include("**/*.class") + include("**/*.class", "META-INF/*.kotlin_module") } tasks.named("remapJar") { enabled = false @@ -225,6 +226,7 @@ subprojects { extensions.configure { runs.configureEach { jvmArguments.add("-Dhollowengine.ide.sourceRoots=${rootProject.layout.buildDirectory.file("ide/source-roots.txt").get().asFile.absolutePath}") + jvmArguments.add("-Dhollowengine.devAddons=${rootProject.layout.buildDirectory.dir("addon-jars").get().asFile.absolutePath}") } } } @@ -282,9 +284,11 @@ tasks.named("buildAndCollect") { val compilerJar = project(":addons:compiler").tasks.named("addonJar") val mcpJar = project(":addons:mcp").tasks.named("addonJar") + val narrativeJar = project(":addons:narrative").tasks.named("addonJar") from(compilerJar.flatMap { it.archiveFile }) from(mcpJar.flatMap { it.archiveFile }) + from(narrativeJar.flatMap { it.archiveFile }) } val buildAddons = tasks.register("buildAddons") { diff --git a/docs/en/addons/creating.mdx b/docs/en/addons/creating.mdx index b804806e3..e027d4150 100644 --- a/docs/en/addons/creating.mdx +++ b/docs/en/addons/creating.mdx @@ -17,7 +17,7 @@ In the HollowEngine repository, every directory under `addons/` that contains a ./gradlew buildAddons ``` -The resulting jars are collected in `build/addon-jars/`. +The resulting jars are collected in `build/addon-jars/`. The repository's own `runClient` and `runServer` build them first and load them from there, so nothing has to be copied into `mods/`. Run `buildAddons` again while the game is open and the rebuilt addons are reloaded in place; one with native libraries still needs a restart. To build an addon outside the repository, compile against the **development jar**. `./gradlew developmentJar` produces `build/development/HollowEngine---dev.jar` and `buildAndCollect` copies it into `merged/`. It exposes the engine classes, the bridge, and bundled libraries on the compile classpath. Use it as `compileOnly` with Mojang or Parchment mappings and the Kotlin version HollowEngine uses. Install the regular universal jar in the game; the development jar is only for compiling. diff --git a/docs/en/addons/index.mdx b/docs/en/addons/index.mdx index 64ed6330b..78735f175 100644 --- a/docs/en/addons/index.mdx +++ b/docs/en/addons/index.mdx @@ -19,6 +19,7 @@ These addons ship with HollowEngine releases: | [MCP server](/docs/hollowengine/addons/mcp) | `hollowengine-mcp` | Lets AI coding agents work with scripts inside the running game. | | [Physics](/docs/hollowengine/addons/physics) | `hollowengine-physics` | Ragdoll physics for models, powered by Jolt. | | [Media](/docs/hollowengine/addons/media) | `hollowengine-media` | Video playback in screens, UI, and by command. | +| [Narrative](/docs/hollowengine/addons/narrative) | `hollowengine-narrative` | Dialogues, the `.story` language, and cutscenes. | ## Installing an addon diff --git a/docs/en/addons/narrative.mdx b/docs/en/addons/narrative.mdx new file mode 100644 index 000000000..52a7b28f5 --- /dev/null +++ b/docs/en/addons/narrative.mdx @@ -0,0 +1,39 @@ +--- +title: Narrative addon +order: 9 +--- + +

Narrative addon

+ +*Dialogues, the `.story` language, and cutscenes.* + +The narrative addon (id `hollowengine-narrative`) holds everything that tells a story on top of the engine. NPCs, their actions, and the node scripts that drive them stay in HollowEngine itself, so a world with NPCs still loads without the addon. + +## What it adds + +- The [dialogue system](/docs/hollowengine/dialogues): `.story` files, `DialogueController`, the dialogue window, and the `/he dialogue advance` and `/he dialogue choose ` commands for playing a dialogue from chat. +- The built-in [story commands](/docs/hollowengine/dialogues/commands) for timing, sound, NPCs, cameras, the HUD, and cutscenes, and `RegisterStoryFunctionsEvent` for [your own](/docs/hollowengine/dialogues/custom-commands). +- `.story` support in the IDE: highlighting, diagnostics, completion, navigation, and a **Dialogue** entry in the project tree's **New script** menu. +- [Cutscenes](/docs/hollowengine/world/cutscenes): the cutscene timeline, `playCutscene` and its variants, and the camera that plays them. + +## Using it from your scripts + +Scripts in the `hollowengine` folder see an addon's classes only when the project depends on it. Add the addon to `dependsOn` in `hollowengine/META-INF/plugin.properties`, or in the IDE's project settings: + +```properties +dependsOn=hollowengine-narrative +``` + +With the dependency in place, node scripts can use `DialogueController`, `DialogueCharacter`, and `playCutscene` without imports, and reload scripts also get the signature helpers (`string`, `number`, `actor`, and the rest) for registering story commands. Without it, those names are unresolved, and the rest of the project compiles as before. + +An addon that uses dialogues or cutscenes declares `dependsOn=hollowengine-narrative` in its own `plugin.properties` in the same way. + +## Packages + +| What | Package | +| --- | --- | +| Dialogues, story functions, events | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue` | +| Signature helpers for story commands | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang` | +| Cutscenes | `ru.hollowhorizon.hollowengine.addons.narrative.cutscene` | + +Scripts written for earlier versions imported these classes from `ru.hollowhorizon.hollowengine.common.dialogue`. Replace that prefix with `ru.hollowhorizon.hollowengine.addons.narrative.dialogue`; cutscene functions used to come from `ru.hollowhorizon.hollowengine.common.scripting.story.functions.player`. diff --git a/docs/en/dialogues/custom-commands.mdx b/docs/en/dialogues/custom-commands.mdx index fdbacfb8e..080ee1caa 100644 --- a/docs/en/dialogues/custom-commands.mdx +++ b/docs/en/dialogues/custom-commands.mdx @@ -17,8 +17,8 @@ Use `RegisterStoryFunctionsEvent` when every dialogue should see a command: ```kotlin import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.RegisterStoryFunctionsEvent -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.RegisterStoryFunctionsEvent +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string @SubscribeEvent fun registerDialogueCommands(event: RegisterStoryFunctionsEvent) { @@ -76,8 +76,8 @@ Use full descriptors when parameters are optional, have defaults, need actor res ```kotlin import kotlinx.coroutines.delay import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string import kotlin.time.Duration.Companion.milliseconds functions.add( diff --git a/docs/en/dialogues/index.mdx b/docs/en/dialogues/index.mdx index a76122fc2..413bb0147 100644 --- a/docs/en/dialogues/index.mdx +++ b/docs/en/dialogues/index.mdx @@ -9,6 +9,10 @@ order: 1 ![Dialogue system](/hollowengine/titles/dialogues/index.webp) +:::advice +Dialogues come with the [narrative addon](/docs/hollowengine/addons/narrative). Scripts in the `hollowengine` folder need `dependsOn=hollowengine-narrative` in the project's `plugin.properties` to use them. +::: + Dialogue scenarios use `.story` files. They live beside Kotlin scripts under `hollowengine/scripts`, but use a dedicated line-based language with highlighting, diagnostics, completion, and navigation in the built-in IDE. For example, `hollowengine/scripts/stories/gate.story` has the address `project:stories/gate.story`. @@ -32,8 +36,8 @@ Adjacent `@choice` blocks form one menu. Only the selected block runs. `if=` hid ## Starting it from Kotlin ```kotlin -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueCharacter -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueController +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueController val gateDialogue = DialogueController("project:stories/gate.story") diff --git a/docs/en/ide/index.mdx b/docs/en/ide/index.mdx index c674bc709..1f0682abe 100644 --- a/docs/en/ide/index.mdx +++ b/docs/en/ide/index.mdx @@ -101,7 +101,7 @@ Place library and mod source archives (`*-sources.jar`, or `.zip`) in `holloweng ### Custom completion -`IdeCompletionEvent` is posted on the client thread, where handlers may read the player and world. It is posted once per caret position. Its `path`, `text`, and `caret` identify the request; `items` starts with the engine's context snippets and accepts additional `UiTextCompletion` entries. The language service's own results are computed in the background and shown next to them. Each entry can specify insertion text, `filterText`, `caretOffset`, and `importFqName`. +`IdeCompletionEvent` is posted on the client thread, where handlers may read the player and world. It is posted once per caret position. Its `path`, `text`, and `caret` identify the request; `items` starts with the engine's context snippets, `pos(...)` at the player's position and `item(...)` for the held item, offered only where an expression of their type may go, and accepts additional `UiTextCompletion` entries. The language service's own results are computed in the background and shown next to them. Each entry can specify insertion text, `filterText`, `caretOffset`, and `importFqName`. ```kotlin import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion diff --git a/docs/en/scripting/npc/data.mdx b/docs/en/scripting/npc/data.mdx index 18e5218db..128fa845a 100644 --- a/docs/en/scripting/npc/data.mdx +++ b/docs/en/scripting/npc/data.mdx @@ -71,7 +71,7 @@ import net.minecraft.client.Minecraft val QUEST_GIVER = dataKey("quest_giver", sync = Sync.TRACKING) { false } overlay("mypack:interact_hint") { - anchor = "crosshair" + anchor(after("crosshair")) autoShow = true rebuildEveryFrame = true // the crosshair target changes every frame, not per packet content { diff --git a/docs/en/scripting/npc/navigation.mdx b/docs/en/scripting/npc/navigation.mdx index cd19634d6..6a55e9359 100644 --- a/docs/en/scripting/npc/navigation.mdx +++ b/docs/en/scripting/npc/navigation.mdx @@ -7,7 +7,7 @@ order: 6 *How an NPC finds its way and walks it, and how to tune that for one NPC or one move.* -Every engine NPC finds and walks its paths the same way, whatever moves it: a script's [`move`](/docs/hollowengine/scripting/npc/movement), a goal such as attacking, or the patrol component. The **Navigation** component tunes this for one NPC. Without it the NPC goes by the defaults shown below, and a script can change them for a single move. +Every engine NPC finds and walks its paths the same way, whatever moves it: a script's [`move`](/docs/hollowengine/scripting/npc/movement), a goal such as attacking, or the patrol component. The **Navigation** component tunes this for one NPC. Without it the NPC goes by the defaults shown below; a script changes them by setting the component. --- @@ -81,36 +81,34 @@ The **Flight** group lets the same `move`, patrol and pathfinding APIs work in t The `minecraft:generic.flying_speed` attribute controls full flight speed in blocks per tick. Change it in the entity's attribute editor. A move's speed modifier multiplies this speed. Flight respects obstacles and body size; it cannot pass through walls. -For example, allow flight for a single move: +For example, let the NPC fly: ```kotlin +import ru.hollowhorizon.hollowengine.common.attachments.api.set import ru.hollowhorizon.hollowengine.common.npcs.navigation.FlightMode import ru.hollowhorizon.hollowengine.common.npcs.navigation.FlightStyle - -npc.move( - pos(40, 80, 12), - MoveOptions(navigation = { - copy(flight = flight.copy( - flies = FlightMode.CAN, - style = FlightStyle.HOVER, - cruiseHeight = 6f, - landAtGoal = true, - )) - }), -) +import ru.hollowhorizon.hollowengine.common.npcs.navigation.NavigationComponent +import ru.hollowhorizon.hollowengine.common.npcs.navigation.navigationComponent + +val settings = npc.navigationComponent ?: NavigationComponent() +npc.set(settings.copy(flight = settings.flight.copy( + flies = FlightMode.CAN, + style = FlightStyle.HOVER, + cruiseHeight = 6f, + landAtGoal = true, +))) +npc.move(pos(40, 80, 12)) ``` Animation expressions expose `q.is_flying`, `q.flight_pitch` and `q.flight_bank`: flight state, trajectory pitch and banking angle. Angles are in degrees. Use them to select a flight clip and tilt the model in its animator. -## Changing the settings for one move +## Changing the settings from a script -`MoveOptions(navigation = ...)` changes the settings for that move only. It is given the NPC's own settings, or the defaults when it has no component, and returns the ones to use: +The settings live in the component only, a move takes none of its own. A script sets the component, starting from the NPC's own settings or the defaults when it has none: ```kotlin -npc.move( - pos(40, 64, 12), - MoveOptions(navigation = { copy(jumps = jumps.copy(maxGap = 3, runUp = 2f)) }), -) +val settings = npc.navigationComponent ?: NavigationComponent() +npc.set(settings.copy(jumps = settings.jumps.copy(maxGap = 3, runUp = 2f))) ``` Zones to keep out of are set from scripts as well, for one move or for good: see [Keeping out of zones](/docs/hollowengine/scripting/npc/movement#keeping-out-of-zones). diff --git a/docs/en/scripting/ui/examples.mdx b/docs/en/scripting/ui/examples.mdx index ff447fd6c..d63cda2d3 100644 --- a/docs/en/scripting/ui/examples.mdx +++ b/docs/en/scripting/ui/examples.mdx @@ -62,8 +62,7 @@ Right-click to open confirmation. β€œYes” removes up to one stone if present; val Hint = dataKey("hint") { "" } overlay("mypack:block_hint") { - anchor = "crosshair" - placement = HudPlacement.AFTER + anchor(after("crosshair")) autoShow = true content { data.find(Hint)?.takeIf(String::isNotBlank)?.let { hint -> diff --git a/docs/en/scripting/ui/ui-files.mdx b/docs/en/scripting/ui/ui-files.mdx index 6fcfaec41..76df745d4 100644 --- a/docs/en/scripting/ui/ui-files.mdx +++ b/docs/en/scripting/ui/ui-files.mdx @@ -81,8 +81,7 @@ The replaced screen has already been detached by the time the swap happens, so c ```kotlin overlay("mypack:stamina") { - anchor = "experience_bar" - placement = HudPlacement.BEFORE + anchor(before("experience_bar")) autoShow = true content { val value = data[Stamina].coerceIn(0f, 1f) @@ -93,15 +92,42 @@ overlay("mypack:stamina") { | Option | Default | Meaning | | --- | --- | --- | -| `anchor` | `hotbar` | Vanilla HUD layer to draw next to; bare names such as `crosshair` and `experience_bar` are accepted | -| `placement` | `HudPlacement.AFTER` | Draw before or after the anchor | +| `anchor(...)` | `after("hotbar")` | Where the overlay is drawn, see below | | `autoShow` | `false` | Show immediately after the script reloads; use for client-only HUD | | `input` | `OverlayInput.NONE` | `NONE` for draw-only HUD, `INTERACTIVE` to receive input | -| `aboveScreens` | `false` | Also draw over an open screen | | `rebuildEveryFrame` | `false` | Recompose each frame; enable only when necessary | `interactive()` is shorthand for `input = OverlayInput.INTERACTIVE`. During gameplay, an interactive overlay uses the screen centre until it receives a pointer event. +### Where the overlay is drawn + +`anchor(...)` takes one or more places, in order of priority: + +| Place | Meaning | +| --- | --- | +| `after("layer")`, `before("layer")` | In the HUD, after or before a vanilla layer. Bare names such as `crosshair`, `chat` and `experience_bar` are accepted | +| `over(ScreenClass::class)` | On top of an open screen of this class or of its subclasses. Pass `subclasses = false` to match the class only | +| `over("binary.class.Name")` | The same for a screen class the script cannot import | +| `over()` | On top of any open screen | + +Every `over` also takes a level: `ScreenLevel.BACKGROUND` draws over the dimmed world but under the screen itself, `ScreenLevel.CONTENT` (the default) draws over the screen but under its tooltips, and `ScreenLevel.TOP` draws over everything. + +Here is a dialogue line that sits above the chat messages and stays clickable while the chat is open: + +```kotlin +import net.minecraft.client.gui.screens.ChatScreen + +overlay("mypack:line") { + anchor(after("chat"), over(ChatScreen::class)) + interactive() + content { Text("Click me in the chat") } +} +``` + +With no screen open, the overlay sits in the HUD at its `after` or `before`. When a screen opens, the first `over` that matches it wins: the overlay is drawn on top of that screen and gets the real cursor. If none matches, the overlay stays in the HUD under the screen and receives no input. An overlay may have only one HUD place; an overlay with only `over` places is hidden while no matching screen is open. + +At the `BACKGROUND` level the screen keeps the keyboard, and the overlay is clicked only where the screen has no widget or container panel of its own. + ## Adaptive surfaces `surface(id)` is for UI that alternates between a HUD overlay and a full screen. For example, a dialogue can stay over gameplay until an answer requires a screen with a cursor. One declaration uses one `content` and one session: @@ -112,7 +138,7 @@ import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind val Choosing = dataKey("choosing") { false } surface("project:dialogue") { - anchor = "hotbar" + anchor(after("hotbar")) mode { state -> if (state[Choosing]) UiSurfaceKind.SCREEN else UiSurfaceKind.OVERLAY } diff --git a/docs/en/world/cutscenes.mdx b/docs/en/world/cutscenes.mdx index e010e09ca..7a14071ca 100644 --- a/docs/en/world/cutscenes.mdx +++ b/docs/en/world/cutscenes.mdx @@ -7,6 +7,10 @@ order: 5 Record camera movement in the editor and play the scene for a chosen player. You can also set the field of view, time of day, and weather on the timeline. +:::advice +Cutscenes come with the [narrative addon](/docs/hollowengine/addons/narrative). Without it the timeline window shows only the timelines of open files, such as montages and effects. +::: + ![Cutscene editor timeline and viewport](/hollowengine/pictures/cutscenes/overview.webp) --- diff --git a/docs/ru/addons/creating.mdx b/docs/ru/addons/creating.mdx index b4a70f169..b48129f33 100644 --- a/docs/ru/addons/creating.mdx +++ b/docs/ru/addons/creating.mdx @@ -17,7 +17,7 @@ order: 2 ./gradlew buildAddons ``` -Π“ΠΎΡ‚ΠΎΠ²Ρ‹Π΅ jar ΡΠΊΠ»Π°Π΄Ρ‹Π²Π°ΡŽΡ‚ΡΡ Π² `build/addon-jars/`. +Π“ΠΎΡ‚ΠΎΠ²Ρ‹Π΅ jar ΡΠΊΠ»Π°Π΄Ρ‹Π²Π°ΡŽΡ‚ΡΡ Π² `build/addon-jars/`. БобствСнныС `runClient` ΠΈ `runServer` рСпозитория сначала ΡΠΎΠ±ΠΈΡ€Π°ΡŽΡ‚ ΠΈΡ… ΠΈ Π·Π°Π³Ρ€ΡƒΠΆΠ°ΡŽΡ‚ прямо ΠΎΡ‚Ρ‚ΡƒΠ΄Π°, Ρ‚Π°ΠΊ Ρ‡Ρ‚ΠΎ ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π½ΠΈΡ‡Π΅Π³ΠΎ Π² `mods/` Π½Π΅ Π½ΡƒΠΆΠ½ΠΎ. ЗапуститС `buildAddons` Π΅Ρ‰Ρ‘ Ρ€Π°Π· ΠΏΡ€ΠΈ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠΉ ΠΈΠ³Ρ€Π΅, ΠΈ пСрСсобранныС Π°Π΄Π΄ΠΎΠ½Ρ‹ пСрСзагрузятся Π½Π° мСстС; Π°Π΄Π΄ΠΎΠ½Ρƒ с Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹ΠΌΠΈ Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠ°ΠΌΠΈ ΠΏΠΎ-ΠΏΡ€Π΅ΠΆΠ½Π΅ΠΌΡƒ Π½ΡƒΠΆΠ΅Π½ пСрСзапуск. Π§Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ±ΠΈΡ€Π°Ρ‚ΡŒ Π°Π΄Π΄ΠΎΠ½ Π²Π½Π΅ рСпозитория, ΠΊΠΎΠΌΠΏΠΈΠ»ΠΈΡ€ΡƒΠΉΡ‚Π΅ ΠΏΡ€ΠΎΡ‚ΠΈΠ² **development jar**. `./gradlew developmentJar` создаёт `build/development/HollowEngine---dev.jar`, Π° `buildAndCollect` ΠΊΠΎΠΏΠΈΡ€ΡƒΠ΅Ρ‚ Π΅Π³ΠΎ Π² `merged/`. Он выставляСт классы Π΄Π²ΠΈΠΆΠΊΠ°, мост ΠΈ встроСнныС Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠΈ Π² compile classpath. ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°ΠΉΡ‚Π΅ Π΅Π³ΠΎ ΠΊΠ°ΠΊ `compileOnly` с ΠΌΠ°ΠΏΠΏΠΈΠ½Π³Π°ΠΌΠΈ Mojang ΠΈΠ»ΠΈ Parchment ΠΈ Ρ‚ΠΎΠΉ вСрсиСй Kotlin, ΠΊΠΎΡ‚ΠΎΡ€ΡƒΡŽ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ HollowEngine. Π’ ΠΈΠ³Ρ€Ρƒ ΡΡ‚Π°Π²ΡŒΡ‚Π΅ ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹ΠΉ ΡƒΠ½ΠΈΠ²Π΅Ρ€ΡΠ°Π»ΡŒΠ½Ρ‹ΠΉ jar; development jar Π½ΡƒΠΆΠ΅Π½ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для компиляции. diff --git a/docs/ru/addons/index.mdx b/docs/ru/addons/index.mdx index 29df95b9c..ac2283484 100644 --- a/docs/ru/addons/index.mdx +++ b/docs/ru/addons/index.mdx @@ -19,6 +19,7 @@ order: 1 | [MCP-сСрвСр](/docs/hollowengine/addons/mcp) | `hollowengine-mcp` | ΠŸΠΎΠ·Π²ΠΎΠ»ΡΠ΅Ρ‚ AI-Π°Π³Π΅Π½Ρ‚Π°ΠΌ для ΠΊΠΎΠ΄Π° Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ со скриптами Π² Π·Π°ΠΏΡƒΡ‰Π΅Π½Π½ΠΎΠΉ ΠΈΠ³Ρ€Π΅. | | [Π€ΠΈΠ·ΠΈΠΊΠ°](/docs/hollowengine/addons/physics) | `hollowengine-physics` | Π€ΠΈΠ·ΠΈΠΊΠ° рэгдоллов для ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ Π½Π° Π΄Π²ΠΈΠΆΠΊΠ΅ Jolt. | | [Media](/docs/hollowengine/addons/media) | `hollowengine-media` | ВоспроизвСдСниС Π²ΠΈΠ΄Π΅ΠΎ Π² экранах, UI ΠΈ ΠΏΠΎ ΠΊΠΎΠΌΠ°Π½Π΄Π΅. | +| [Narrative](/docs/hollowengine/addons/narrative) | `hollowengine-narrative` | Π”ΠΈΠ°Π»ΠΎΠ³ΠΈ, язык `.story` ΠΈ катсцСны. | ## Установка Π°Π΄Π΄ΠΎΠ½Π° diff --git a/docs/ru/addons/narrative.mdx b/docs/ru/addons/narrative.mdx new file mode 100644 index 000000000..07042737f --- /dev/null +++ b/docs/ru/addons/narrative.mdx @@ -0,0 +1,39 @@ +--- +title: Аддон Narrative +order: 9 +--- + +

Аддон Narrative

+ +*Π”ΠΈΠ°Π»ΠΎΠ³ΠΈ, язык `.story` ΠΈ катсцСны.* + +Аддон Narrative (id `hollowengine-narrative`) содСрТит всё, Ρ‡Ρ‚ΠΎ рассказываСт ΠΈΡΡ‚ΠΎΡ€ΠΈΡŽ ΠΏΠΎΠ²Π΅Ρ€Ρ… Π΄Π²ΠΈΠΆΠΊΠ°. NPC, ΠΈΡ… дСйствия ΠΈ ΡƒΠΏΡ€Π°Π²Π»ΡΡŽΡ‰ΠΈΠ΅ ΠΈΠΌΠΈ Π½ΠΎΠ΄-скрипты ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ Π² самом HollowEngine, поэтому ΠΌΠΈΡ€ с NPC загруТаСтся ΠΈ Π±Π΅Π· Π°Π΄Π΄ΠΎΠ½Π°. + +## Π§Ρ‚ΠΎ ΠΎΠ½ добавляСт + +- [Π”ΠΈΠ°Π»ΠΎΠ³ΠΎΠ²ΡƒΡŽ систСму](/docs/hollowengine/dialogues): Ρ„Π°ΠΉΠ»Ρ‹ `.story`, `DialogueController`, ΠΎΠΊΠ½ΠΎ Π΄ΠΈΠ°Π»ΠΎΠ³Π° ΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ `/he dialogue advance` ΠΈ `/he dialogue choose `, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ΡŒ Π΄ΠΈΠ°Π»ΠΎΠ³ Ρ‡Π΅Ρ€Π΅Π· Ρ‡Π°Ρ‚. +- ВстроСнныС [ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ историй](/docs/hollowengine/dialogues/commands) для ΠΏΠ°ΡƒΠ·, Π·Π²ΡƒΠΊΠ°, NPC, ΠΊΠ°ΠΌΠ΅Ρ€Ρ‹, HUD ΠΈ катсцСн, Π° Ρ‚Π°ΠΊΠΆΠ΅ `RegisterStoryFunctionsEvent` для [своих ΠΊΠΎΠΌΠ°Π½Π΄](/docs/hollowengine/dialogues/custom-commands). +- ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΡƒ `.story` Π² IDE: подсвСтку, диагностику, Π°Π²Ρ‚ΠΎΠ΄ΠΎΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ ΠΈ ΠΏΡƒΠ½ΠΊΡ‚ **Dialogue** Π² мСню **New script** Π΄Π΅Ρ€Π΅Π²Π° ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°. +- [ΠšΠ°Ρ‚ΡΡ†Π΅Π½Ρ‹](/docs/hollowengine/world/cutscenes): Ρ‚Π°ΠΉΠΌΠ»Π°ΠΉΠ½ катсцСны, `playCutscene` с Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π°ΠΌΠΈ ΠΈ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ, которая ΠΈΡ… ΠΏΡ€ΠΎΠΈΠ³Ρ€Ρ‹Π²Π°Π΅Ρ‚. + +## ИспользованиС ΠΈΠ· скриптов + +Π‘ΠΊΡ€ΠΈΠΏΡ‚Ρ‹ ΠΏΠ°ΠΏΠΊΠΈ `hollowengine` видят классы Π°Π΄Π΄ΠΎΠ½Π°, Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ ΠΎΡ‚ Π½Π΅Π³ΠΎ зависит. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ Π°Π΄Π΄ΠΎΠ½ Π² `dependsOn` Π² `hollowengine/META-INF/plugin.properties` ΠΈΠ»ΠΈ Π² настройках ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° Π² IDE: + +```properties +dependsOn=hollowengine-narrative +``` + +Π‘ этой Π·Π°Π²ΠΈΡΠΈΠΌΠΎΡΡ‚ΡŒΡŽ Π½ΠΎΠ΄-скрипты ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡŽΡ‚ `DialogueController`, `DialogueCharacter` ΠΈ `playCutscene` Π±Π΅Π· ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΎΠ², Π° reload-скрипты ΠΏΠΎΠ»ΡƒΡ‡Π°ΡŽΡ‚ Π΅Ρ‰Ρ‘ ΠΈ ΠΏΠΎΠΌΠΎΡ‰Π½ΠΈΠΊΠΈ сигнатур (`string`, `number`, `actor` ΠΈ ΠΎΡΡ‚Π°Π»ΡŒΠ½Ρ‹Π΅) для рСгистрации ΠΊΠΎΠΌΠ°Π½Π΄ историй. Π‘Π΅Π· Π½Π΅Ρ‘ эти ΠΈΠΌΠ΅Π½Π° Π½Π΅ находятся, Π° ΠΎΡΡ‚Π°Π»ΡŒΠ½ΠΎΠΉ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ компилируСтся ΠΊΠ°ΠΊ Ρ€Π°Π½ΡŒΡˆΠ΅. + +Аддон, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ Π΄ΠΈΠ°Π»ΠΎΠ³Π°ΠΌΠΈ ΠΈΠ»ΠΈ катсцСнами, Ρ‚Π°ΠΊ ΠΆΠ΅ ΠΎΠ±ΡŠΡΠ²Π»ΡΠ΅Ρ‚ `dependsOn=hollowengine-narrative` Π² своём `plugin.properties`. + +## ΠŸΠ°ΠΊΠ΅Ρ‚Ρ‹ + +| Π§Ρ‚ΠΎ | ΠŸΠ°ΠΊΠ΅Ρ‚ | +| --- | --- | +| Π”ΠΈΠ°Π»ΠΎΠ³ΠΈ, Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ историй, события | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue` | +| ΠŸΠΎΠΌΠΎΡ‰Π½ΠΈΠΊΠΈ сигнатур для ΠΊΠΎΠΌΠ°Π½Π΄ историй | `ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang` | +| ΠšΠ°Ρ‚ΡΡ†Π΅Π½Ρ‹ | `ru.hollowhorizon.hollowengine.addons.narrative.cutscene` | + +Π‘ΠΊΡ€ΠΈΠΏΡ‚Ρ‹ для ΠΏΡ€ΠΎΡˆΠ»Ρ‹Ρ… вСрсий ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π»ΠΈ эти классы ΠΈΠ· `ru.hollowhorizon.hollowengine.common.dialogue`. Π—Π°ΠΌΠ΅Π½ΠΈΡ‚Π΅ этот прСфикс Π½Π° `ru.hollowhorizon.hollowengine.addons.narrative.dialogue`; Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ катсцСн Ρ€Π°Π½ΡŒΡˆΠ΅ Π»Π΅ΠΆΠ°Π»ΠΈ Π² `ru.hollowhorizon.hollowengine.common.scripting.story.functions.player`. diff --git a/docs/ru/dialogues/custom-commands.mdx b/docs/ru/dialogues/custom-commands.mdx index 40eec232b..7c5310f82 100644 --- a/docs/ru/dialogues/custom-commands.mdx +++ b/docs/ru/dialogues/custom-commands.mdx @@ -17,8 +17,8 @@ order: 5 ```kotlin import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.RegisterStoryFunctionsEvent -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.RegisterStoryFunctionsEvent +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string @SubscribeEvent fun registerDialogueCommands(event: RegisterStoryFunctionsEvent) { @@ -76,8 +76,8 @@ functions.add( ```kotlin import kotlinx.coroutines.delay import net.minecraft.network.chat.Component -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.number +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.lang.string import kotlin.time.Duration.Companion.milliseconds functions.add( diff --git a/docs/ru/dialogues/index.mdx b/docs/ru/dialogues/index.mdx index 6af32e871..a715b3dc8 100644 --- a/docs/ru/dialogues/index.mdx +++ b/docs/ru/dialogues/index.mdx @@ -9,6 +9,10 @@ order: 1 ![Π”ΠΈΠ°Π»ΠΎΠ³](/hollowengine/titles/dialogues/index.webp) +:::advice +Π”ΠΈΠ°Π»ΠΎΠ³ΠΈ входят Π² [Π°Π΄Π΄ΠΎΠ½ Narrative](/docs/hollowengine/addons/narrative). Π‘ΠΊΡ€ΠΈΠΏΡ‚Π°ΠΌ ΠΏΠ°ΠΏΠΊΠΈ `hollowengine` для Ρ€Π°Π±ΠΎΡ‚Ρ‹ с Π½ΠΈΠΌΠΈ Π½ΡƒΠΆΠ΅Π½ `dependsOn=hollowengine-narrative` Π² `plugin.properties` ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°. +::: + Π‘Ρ†Π΅Π½Π°Ρ€ΠΈΠΈ Π΄ΠΈΠ°Π»ΠΎΠ³ΠΎΠ² ΠΏΠΈΡˆΡƒΡ‚ΡΡ Π² `.story` Ρ„Π°ΠΉΠ»Π°Ρ…. Они Π»Π΅ΠΆΠ°Ρ‚ рядом с Kotlin-скриптами Π² `hollowengine/scripts`, Π½ΠΎ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡŽΡ‚ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ построчный язык. ВстроСнная IDE ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅Ρ‚ Π΅Π³ΠΎ подсвСтку, диагностику, Π°Π²Ρ‚ΠΎΠ΄ΠΎΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΠΈ Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ. НапримСр, Ρ„Π°ΠΉΠ» `hollowengine/scripts/stories/gate.story` ΠΈΠΌΠ΅Π΅Ρ‚ адрСс `project:stories/gate.story`. @@ -32,8 +36,8 @@ order: 1 ## Запуск ΠΈΠ· Kotlin ```kotlin -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueCharacter -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueController +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueCharacter +import ru.hollowhorizon.hollowengine.addons.narrative.dialogue.DialogueController val gateDialogue = DialogueController("project:stories/gate.story") diff --git a/docs/ru/ide/index.mdx b/docs/ru/ide/index.mdx index edd33b7ab..50d85d3e9 100644 --- a/docs/ru/ide/index.mdx +++ b/docs/ru/ide/index.mdx @@ -100,7 +100,7 @@ order: 1 ### Π‘Π²ΠΎΠΈ прСдлоТСния автодополнСния -`IdeCompletionEvent` вызываСтся Π½Π° клиСнтском ΠΏΠΎΡ‚ΠΎΠΊΠ΅: ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ ΠΌΠΎΠΆΠ΅Ρ‚ бСзопасно Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ состояниС ΠΈΠ³Ρ€ΠΎΠΊΠ° ΠΈ ΠΌΠΈΡ€Π°. Π‘ΠΎΠ±Ρ‹Ρ‚ΠΈΠ΅ ΠΏΡ€ΠΈΡ…ΠΎΠ΄ΠΈΡ‚ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π· Π½Π° ΠΏΠΎΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Π΅Ρ‚ΠΊΠΈ. Поля `path`, `text` ΠΈ `caret` ΠΎΠΏΠΈΡΡ‹Π²Π°ΡŽΡ‚ запрос; `items` начинаСтся с контСкстных сниппСтов Π΄Π²ΠΈΠΆΠΊΠ°, ΠΈ Ρ‚ΡƒΠ΄Π° ΠΌΠΎΠΆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»ΡΡ‚ΡŒ `UiTextCompletion`. Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Ρ‹ самого языкового сСрвиса ΡΡ‡ΠΈΡ‚Π°ΡŽΡ‚ΡΡ Π² Ρ„ΠΎΠ½Π΅ ΠΈ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°ΡŽΡ‚ΡΡ рядом. Π£ прСдлоТСния ΠΌΠΎΠΆΠ½ΠΎ Π·Π°Π΄Π°Ρ‚ΡŒ вставляСмый тСкст, имя для Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ `filterText`, ΠΏΠΎΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Π΅Ρ‚ΠΊΠΈ `caretOffset` ΠΈ ΠΈΠΌΠΏΠΎΡ€Ρ‚ `importFqName`. +`IdeCompletionEvent` вызываСтся Π½Π° клиСнтском ΠΏΠΎΡ‚ΠΎΠΊΠ΅: ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ ΠΌΠΎΠΆΠ΅Ρ‚ бСзопасно Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ состояниС ΠΈΠ³Ρ€ΠΎΠΊΠ° ΠΈ ΠΌΠΈΡ€Π°. Π‘ΠΎΠ±Ρ‹Ρ‚ΠΈΠ΅ ΠΏΡ€ΠΈΡ…ΠΎΠ΄ΠΈΡ‚ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π· Π½Π° ΠΏΠΎΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Π΅Ρ‚ΠΊΠΈ. Поля `path`, `text` ΠΈ `caret` ΠΎΠΏΠΈΡΡ‹Π²Π°ΡŽΡ‚ запрос; `items` начинаСтся с контСкстных сниппСтов Π΄Π²ΠΈΠΆΠΊΠ° - `pos(...)` с ΠΏΠΎΠ·ΠΈΡ†ΠΈΠ΅ΠΉ ΠΈΠ³Ρ€ΠΎΠΊΠ° ΠΈ `item(...)` с ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚ΠΎΠΌ Π² Ρ€ΡƒΠΊΠ΅, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΏΡ€Π΅Π΄Π»Π°Π³Π°ΡŽΡ‚ΡΡ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρ‚Π°ΠΌ, Π³Π΄Π΅ допустимо Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΈΡ… Ρ‚ΠΈΠΏΠ°, - ΠΈ Ρ‚ΡƒΠ΄Π° ΠΌΠΎΠΆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»ΡΡ‚ΡŒ `UiTextCompletion`. Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Ρ‹ самого языкового сСрвиса ΡΡ‡ΠΈΡ‚Π°ΡŽΡ‚ΡΡ Π² Ρ„ΠΎΠ½Π΅ ΠΈ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°ΡŽΡ‚ΡΡ рядом. Π£ прСдлоТСния ΠΌΠΎΠΆΠ½ΠΎ Π·Π°Π΄Π°Ρ‚ΡŒ вставляСмый тСкст, имя для Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ `filterText`, ΠΏΠΎΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Π΅Ρ‚ΠΊΠΈ `caretOffset` ΠΈ ΠΈΠΌΠΏΠΎΡ€Ρ‚ `importFqName`. ```kotlin import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion diff --git a/docs/ru/scripting/npc/data.mdx b/docs/ru/scripting/npc/data.mdx index b2ef08f4d..b4277445c 100644 --- a/docs/ru/scripting/npc/data.mdx +++ b/docs/ru/scripting/npc/data.mdx @@ -71,7 +71,7 @@ import net.minecraft.client.Minecraft val QUEST_GIVER = dataKey("quest_giver", sync = Sync.TRACKING) { false } overlay("mypack:interact_hint") { - anchor = "crosshair" + anchor(after("crosshair")) autoShow = true rebuildEveryFrame = true // Ρ†Π΅Π»ΡŒ ΠΏΠΎΠ΄ ΠΏΡ€ΠΈΡ†Π΅Π»ΠΎΠΌ мСняСтся ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ ΠΊΠ°Π΄Ρ€, Π° Π½Π΅ ΠΏΠΎ ΠΏΠ°ΠΊΠ΅Ρ‚Ρƒ content { diff --git a/docs/ru/scripting/npc/navigation.mdx b/docs/ru/scripting/npc/navigation.mdx index 84b55f760..2b107f79b 100644 --- a/docs/ru/scripting/npc/navigation.mdx +++ b/docs/ru/scripting/npc/navigation.mdx @@ -7,7 +7,7 @@ order: 6 *Как NPC ΠΈΡ‰Π΅Ρ‚ ΠΏΡƒΡ‚ΡŒ ΠΈ ΠΈΠ΄Ρ‘Ρ‚ ΠΏΠΎ Π½Π΅ΠΌΡƒ, ΠΈ ΠΊΠ°ΠΊ это Π½Π°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ для ΠΎΠ΄Π½ΠΎΠ³ΠΎ NPC ΠΈΠ»ΠΈ ΠΎΠ΄Π½ΠΎΠ³ΠΎ пСрСмСщСния.* -ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ NPC Π΄Π²ΠΈΠΆΠΊΠ° ΠΈΡ‰Π΅Ρ‚ ΠΏΡƒΡ‚ΡŒ ΠΈ ΠΈΠ΄Ρ‘Ρ‚ ΠΏΠΎ Π½Π΅ΠΌΡƒ ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²ΠΎ, Ρ‡Ρ‚ΠΎ Π±Ρ‹ Π΅Π³ΠΎ Π½ΠΈ Π²Π΅Π»ΠΎ: [`move`](/docs/hollowengine/scripting/npc/movement) ΠΈΠ· скрипта, Ρ†Π΅Π»ΡŒ Π²Ρ€ΠΎΠ΄Π΅ Π°Ρ‚Π°ΠΊΠΈ ΠΈΠ»ΠΈ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ патрулирования. ΠšΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ **«Навигация»** настраиваСт это для ΠΎΠ΄Π½ΠΎΠ³ΠΎ NPC. Π‘Π΅Π· Π½Π΅Π³ΠΎ NPC ΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ значСниями ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ΠΈΠ· Ρ‚Π°Π±Π»ΠΈΡ† Π½ΠΈΠΆΠ΅, Π° скрипт ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΎΠΌΠ΅Π½ΡΡ‚ΡŒ ΠΈΡ… Π½Π° ΠΎΠ΄Π½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅. +ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ NPC Π΄Π²ΠΈΠΆΠΊΠ° ΠΈΡ‰Π΅Ρ‚ ΠΏΡƒΡ‚ΡŒ ΠΈ ΠΈΠ΄Ρ‘Ρ‚ ΠΏΠΎ Π½Π΅ΠΌΡƒ ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²ΠΎ, Ρ‡Ρ‚ΠΎ Π±Ρ‹ Π΅Π³ΠΎ Π½ΠΈ Π²Π΅Π»ΠΎ: [`move`](/docs/hollowengine/scripting/npc/movement) ΠΈΠ· скрипта, Ρ†Π΅Π»ΡŒ Π²Ρ€ΠΎΠ΄Π΅ Π°Ρ‚Π°ΠΊΠΈ ΠΈΠ»ΠΈ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ патрулирования. ΠšΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ **«Навигация»** настраиваСт это для ΠΎΠ΄Π½ΠΎΠ³ΠΎ NPC. Π‘Π΅Π· Π½Π΅Π³ΠΎ NPC ΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ значСниями ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ΠΈΠ· Ρ‚Π°Π±Π»ΠΈΡ† Π½ΠΈΠΆΠ΅; скрипт мСняСт ΠΈΡ…, задавая ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚. --- @@ -81,36 +81,34 @@ NPC, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΡƒΠΆΠ΅ стоит Π½Π° Π±Π»ΠΎΠΊΠ΅, ΠΊΡƒΠ΄Π° Π΅ΠΌΡƒ нСль Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ ΠΏΠΎΠ»Ρ‘Ρ‚Π° Π·Π°Π΄Π°Ρ‘Ρ‚ Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ `minecraft:generic.flying_speed`, Π² Π±Π»ΠΎΠΊΠ°Ρ… Π·Π° Ρ‚ΠΈΠΊ. Π•Π³ΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π² Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π΅ Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ΠΎΠ² сущности. Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ пСрСмСщСния ΡƒΠΌΠ½ΠΎΠΆΠ°Π΅Ρ‚ эту ΡΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ. ΠŸΠΎΠ»Ρ‘Ρ‚ ΡƒΡ‡ΠΈΡ‚Ρ‹Π²Π°Π΅Ρ‚ прСпятствия ΠΈ Ρ€Π°Π·ΠΌΠ΅Ρ€ Ρ‚Π΅Π»Π° ΠΈ Π½Π΅ позволяСт ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ΡŒ Ρ‡Π΅Ρ€Π΅Π· стСны. -НапримСр, Ρ€Π°Π·Ρ€Π΅ΡˆΠΈΡ‚ΡŒ ΠΏΠΎΠ»Ρ‘Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½Π° ΠΎΠ΄Π½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅: +НапримСр, Ρ€Π°Π·Ρ€Π΅ΡˆΠΈΡ‚ΡŒ NPC Π»Π΅Ρ‚Π°Ρ‚ΡŒ: ```kotlin +import ru.hollowhorizon.hollowengine.common.attachments.api.set import ru.hollowhorizon.hollowengine.common.npcs.navigation.FlightMode import ru.hollowhorizon.hollowengine.common.npcs.navigation.FlightStyle - -npc.move( - pos(40, 80, 12), - MoveOptions(navigation = { - copy(flight = flight.copy( - flies = FlightMode.CAN, - style = FlightStyle.HOVER, - cruiseHeight = 6f, - landAtGoal = true, - )) - }), -) +import ru.hollowhorizon.hollowengine.common.npcs.navigation.NavigationComponent +import ru.hollowhorizon.hollowengine.common.npcs.navigation.navigationComponent + +val settings = npc.navigationComponent ?: NavigationComponent() +npc.set(settings.copy(flight = settings.flight.copy( + flies = FlightMode.CAN, + style = FlightStyle.HOVER, + cruiseHeight = 6f, + landAtGoal = true, +))) +npc.move(pos(40, 80, 12)) ``` Для Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ доступны `q.is_flying`, `q.flight_pitch` ΠΈ `q.flight_bank`: Ρ„Π°ΠΊΡ‚ ΠΏΠΎΠ»Ρ‘Ρ‚Π°, Π½Π°ΠΊΠ»ΠΎΠ½ Ρ‚Ρ€Π°Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ ΠΈ ΠΊΡ€Π΅Π½ Π² ΠΏΠΎΠ²ΠΎΡ€ΠΎΡ‚Π΅. Π£Π³Π»Ρ‹ Π·Π°Π΄Π°ΡŽΡ‚ΡΡ Π² градусах. Они ΠΏΠΎΠ·Π²ΠΎΠ»ΡΡŽΡ‚ ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ ΠΊΠ»ΠΈΠΏ ΠΏΠΎΠ»Ρ‘Ρ‚Π° ΠΈ Π½Π°ΠΊΠ»ΠΎΠ½ΡΡ‚ΡŒ модСль Π² Π°Π½ΠΈΠΌΠ°Ρ‚ΠΎΡ€Π΅. -## Настройки Π½Π° ΠΎΠ΄Π½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅ +## Настройки ΠΈΠ· скрипта -`MoveOptions(navigation = ...)` мСняСт настройки Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½Π° это ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅. Ѐункция ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ настройки самого NPC (ΠΈΠ»ΠΈ значСния ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ, Ссли ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Π° Π½Π΅Ρ‚) ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Ρ‚Π΅, с ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΌΠΈ ΠΈΠ΄Ρ‚ΠΈ: +Настройки ΠΆΠΈΠ²ΡƒΡ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Π΅, Ρƒ пСрСмСщСния своих Π½Π΅Ρ‚. Π‘ΠΊΡ€ΠΈΠΏΡ‚ Π·Π°Π΄Π°Ρ‘Ρ‚ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚, взяв Π·Π° основу настройки самого NPC ΠΈΠ»ΠΈ значСния ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ, Ссли ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Π° Π½Π΅Ρ‚: ```kotlin -npc.move( - pos(40, 64, 12), - MoveOptions(navigation = { copy(jumps = jumps.copy(maxGap = 3, runUp = 2f)) }), -) +val settings = npc.navigationComponent ?: NavigationComponent() +npc.set(settings.copy(jumps = settings.jumps.copy(maxGap = 3, runUp = 2f))) ``` Π—ΠΎΠ½Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ Π½ΡƒΠΆΠ½ΠΎ ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΡ‚ΡŒ, Ρ‚ΠΎΠΆΠ΅ Π·Π°Π΄Π°ΡŽΡ‚ΡΡ ΠΈΠ· скриптов - Π½Π° ΠΎΠ΄Π½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅ ΠΈΠ»ΠΈ насовсСм: см. [ΠžΠ±Ρ…ΠΎΠ΄ Π·ΠΎΠ½](/docs/hollowengine/scripting/npc/movement#keeping-out-of-zones). diff --git a/docs/ru/scripting/ui/examples.mdx b/docs/ru/scripting/ui/examples.mdx index c6af9a22a..3ac3408c2 100644 --- a/docs/ru/scripting/ui/examples.mdx +++ b/docs/ru/scripting/ui/examples.mdx @@ -62,8 +62,7 @@ onInteract { event -> val Hint = dataKey("hint") { "" } overlay("mypack:block_hint") { - anchor = "crosshair" - placement = HudPlacement.AFTER + anchor(after("crosshair")) autoShow = true content { data.find(Hint)?.takeIf(String::isNotBlank)?.let { hint -> diff --git a/docs/ru/scripting/ui/ui-files.mdx b/docs/ru/scripting/ui/ui-files.mdx index 70981b1b7..f05f26889 100644 --- a/docs/ru/scripting/ui/ui-files.mdx +++ b/docs/ru/scripting/ui/ui-files.mdx @@ -81,8 +81,7 @@ screen("mypack:menus") { ```kotlin overlay("mypack:stamina") { - anchor = "experience_bar" - placement = HudPlacement.BEFORE + anchor(before("experience_bar")) autoShow = true content { val value = data[Stamina].coerceIn(0f, 1f) @@ -91,17 +90,44 @@ overlay("mypack:stamina") { } ``` -| ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ | По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ | Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ | -|---------------------|----------------------|-------------------------------------------------------------------------------------| -| `anchor` | `hotbar` | Π‘Π»ΠΎΠΉ vanilla HUD; допустимы ΠΊΠΎΡ€ΠΎΡ‚ΠΊΠΈΠ΅ ΠΈΠΌΠ΅Π½Π°, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `crosshair` ΠΈ `experience_bar` | -| `placement` | `HudPlacement.AFTER` | Π ΠΈΡΠΎΠ²Π°Ρ‚ΡŒ Π΄ΠΎ ΠΈΠ»ΠΈ послС anchor | -| `autoShow` | `false` | ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ сразу послС ΠΏΠ΅Ρ€Π΅Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ; для клиСнтского HUD | -| `input` | `OverlayInput.NONE` | `NONE` для HUD Π±Π΅Π· Π²Π²ΠΎΠ΄Π°, `INTERACTIVE` для получСния Π²Π²ΠΎΠ΄Π° | -| `aboveScreens` | `false` | Π’Π°ΠΊΠΆΠ΅ Ρ€ΠΈΡΠΎΠ²Π°Ρ‚ΡŒ ΠΏΠΎΠ²Π΅Ρ€Ρ… ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠ³ΠΎ экрана | -| `rebuildEveryFrame` | `false` | ΠŸΠ΅Ρ€Π΅ΠΊΠΎΠΌΠΏΠΎΠ½ΠΎΠ²Ρ‹Π²Π°Ρ‚ΡŒ ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ ΠΊΠ°Π΄Ρ€; Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΊΠΎΠ³Π΄Π° это Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ | +| ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ | По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ | Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ | +|---------------------|---------------------|------------------------------------------------------------| +| `anchor(...)` | `after("hotbar")` | Π“Π΄Π΅ рисуСтся ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ, ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Π΅Π΅ Π½ΠΈΠΆΠ΅ | +| `autoShow` | `false` | ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ сразу послС ΠΏΠ΅Ρ€Π΅Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ; для клиСнтского HUD | +| `input` | `OverlayInput.NONE` | `NONE` для HUD Π±Π΅Π· Π²Π²ΠΎΠ΄Π°, `INTERACTIVE` для получСния Π²Π²ΠΎΠ΄Π° | +| `rebuildEveryFrame` | `false` | ΠŸΠ΅Ρ€Π΅ΠΊΠΎΠΌΠΏΠΎΠ½ΠΎΠ²Ρ‹Π²Π°Ρ‚ΡŒ ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ ΠΊΠ°Π΄Ρ€; Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΊΠΎΠ³Π΄Π° это Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ | `interactive()` сокращаСт запись `input = OverlayInput.INTERACTIVE`. Π’ΠΎ врСмя ΠΈΠ³Ρ€Ρ‹ ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ Ρ†Π΅Π½Ρ‚Ρ€ экрана, ΠΏΠΎΠΊΠ° Π½Π΅ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ событиС указатСля. +### Π“Π΄Π΅ рисуСтся ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ + +`anchor(...)` ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅Ρ‚ ΠΎΠ΄Π½ΠΎ ΠΈΠ»ΠΈ нСсколько мСст Π² порядкС ΠΏΡ€ΠΈΠΎΡ€ΠΈΡ‚Π΅Ρ‚Π°: + +| ΠœΠ΅ΡΡ‚ΠΎ | Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ | +|-------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `after("слой")`, `before("слой")` | Π’ HUD послС ΠΈΠ»ΠΈ Π΄ΠΎ слоя vanilla. Допустимы ΠΊΠΎΡ€ΠΎΡ‚ΠΊΠΈΠ΅ ΠΈΠΌΠ΅Π½Π°, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `crosshair`, `chat` ΠΈ `experience_bar` | +| `over(ΠšΠ»Π°ΡΡΠ­ΠΊΡ€Π°Π½Π°::class)` | ΠŸΠΎΠ²Π΅Ρ€Ρ… ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠ³ΠΎ экрана этого класса ΠΈΠ»ΠΈ Π΅Π³ΠΎ наслСдников. Π‘ `subclasses = false` ΠΏΠΎΠ΄ΠΎΠΉΠ΄Ρ‘Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ сам класс | +| `over("binary.class.Name")` | Π’ΠΎ ΠΆΠ΅ для класса экрана, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ скрипт Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ | +| `over()` | ΠŸΠΎΠ²Π΅Ρ€Ρ… любого ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠ³ΠΎ экрана | + +ΠšΠ°ΠΆΠ΄ΠΎΠΌΡƒ `over` ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‚ΡŒ ΡƒΡ€ΠΎΠ²Π΅Π½ΡŒ. `ScreenLevel.BACKGROUND` рисуСт ΠΏΠΎΠ²Π΅Ρ€Ρ… Π·Π°Ρ‚Π΅ΠΌΠ½Ρ‘Π½Π½ΠΎΠ³ΠΎ ΠΌΠΈΡ€Π°, Π½ΠΎ ΠΏΠΎΠ΄ самим экраном. `ScreenLevel.CONTENT` (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ) рисуСт ΠΏΠΎΠ²Π΅Ρ€Ρ… экрана, Π½ΠΎ ΠΏΠΎΠ΄ Π΅Π³ΠΎ подсказками. `ScreenLevel.TOP` рисуСт ΠΏΠΎΠ²Π΅Ρ€Ρ… всСго. + +НапримСр, Ρ€Π΅ΠΏΠ»ΠΈΠΊΠ° Π΄ΠΈΠ°Π»ΠΎΠ³Π° Π½Π°Π΄ сообщСниями Ρ‡Π°Ρ‚Π°, ΠΏΠΎ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠΉ ΠΌΠΎΠΆΠ½ΠΎ ΠΊΠ»ΠΈΠΊΠ½ΡƒΡ‚ΡŒ Π² ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠΌ Ρ‡Π°Ρ‚Π΅: + +```kotlin +import net.minecraft.client.gui.screens.ChatScreen + +overlay("mypack:line") { + anchor(after("chat"), over(ChatScreen::class)) + interactive() + content { Text("ΠšΠ»ΠΈΠΊΠ½ΠΈΡ‚Π΅ ΠΏΠΎ ΠΌΠ½Π΅ Π² Ρ‡Π°Ρ‚Π΅") } +} +``` + +Пока экран Π·Π°ΠΊΡ€Ρ‹Ρ‚, ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ стоит Π² HUD Π½Π° мСстС `after` ΠΈΠ»ΠΈ `before`. Когда открываСтся экран, срабатываСт ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ подходящий `over`: ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ рисуСтся ΠΏΠΎΠ²Π΅Ρ€Ρ… этого экрана ΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ настоящий курсор. Если Π½ΠΈ ΠΎΠ΄ΠΈΠ½ `over` Π½Π΅ ΠΏΠΎΠ΄ΠΎΡˆΡ‘Π», ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ остаётся Π² HUD ΠΏΠΎΠ΄ экраном ΠΈ Π²Π²ΠΎΠ΄Π° Π½Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚. ΠœΠ΅ΡΡ‚ΠΎ Π² HUD Ρƒ овСрлСя ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄Π½ΠΎ. ΠžΠ²Π΅Ρ€Π»Π΅ΠΉ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ с `over` скрыт, ΠΏΠΎΠΊΠ° Π½Π΅ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ подходящий экран. + +На ΡƒΡ€ΠΎΠ²Π½Π΅ `BACKGROUND` ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€Π° остаётся Ρƒ экрана. ΠšΠ»ΠΈΠΊΠ½ΡƒΡ‚ΡŒ ΠΏΠΎ ΠΎΠ²Π΅Ρ€Π»Π΅ΡŽ ΠΌΠΎΠΆΠ½ΠΎ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρ‚Π°ΠΌ, Π³Π΄Π΅ Ρƒ экрана Π½Π΅Ρ‚ своСго Π²ΠΈΠ΄ΠΆΠ΅Ρ‚Π° ΠΈΠ»ΠΈ ΠΏΠ°Π½Π΅Π»ΠΈ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π°. + ## Адаптивная ΠΏΠΎΠ²Π΅Ρ€Ρ…Π½ΠΎΡΡ‚ΡŒ `surface(id)` ΠΏΠΎΠ΄Ρ…ΠΎΠ΄ΠΈΡ‚ для интСрфСйса, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΈΠ½ΠΎΠ³Π΄Π° являСтся HUD, Π° ΠΈΠ½ΠΎΠ³Π΄Π° - ΠΏΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Ρ‹ΠΌ экраном. НапримСр, Π΄ΠΈΠ°Π»ΠΎΠ³ Π±Π΅Π· Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ΠΎΠ² ΠΎΡ‚Π²Π΅Ρ‚Π° ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ ΠΏΠΎΠ²Π΅Ρ€Ρ… ΠΈΠ³Ρ€Ρ‹, Π° ΠΏΡ€ΠΈ Π²Ρ‹Π±ΠΎΡ€Π΅ ΠΎΡ‚Π²Π΅Ρ‚Π° Π²ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ экран с курсором. ОбъявлСниС ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ ΠΎΠ΄ΠΈΠ½ `content` ΠΈ ΠΎΠ΄Π½Ρƒ сСссию: @@ -112,7 +138,7 @@ import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind val Choosing = dataKey("choosing") { false } surface("project:dialogue") { - anchor = "hotbar" + anchor(after("hotbar")) mode { state -> if (state[Choosing]) UiSurfaceKind.SCREEN else UiSurfaceKind.OVERLAY } diff --git a/docs/ru/world/cutscenes.mdx b/docs/ru/world/cutscenes.mdx index 517d7bb41..ae0126caa 100644 --- a/docs/ru/world/cutscenes.mdx +++ b/docs/ru/world/cutscenes.mdx @@ -7,6 +7,10 @@ order: 5 Π—Π°ΠΏΠΈΡˆΠΈΡ‚Π΅ Π΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ ΠΊΠ°ΠΌΠ΅Ρ€Ρ‹ Π² Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π΅ ΠΈ запуститС сцСну для Π²Ρ‹Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ ΠΈΠ³Ρ€ΠΎΠΊΠ°. На Ρ‚Π°ΠΉΠΌΠ»Π°ΠΉΠ½Π΅ ΠΌΠΎΠΆΠ½ΠΎ Ρ‚Π°ΠΊΠΆΠ΅ Π½Π°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ ΠΏΠΎΠ»Π΅ зрСния, врСмя суток ΠΈ ΠΏΠΎΠ³ΠΎΠ΄Ρƒ. +:::advice +ΠšΠ°Ρ‚ΡΡ†Π΅Π½Ρ‹ входят Π² [Π°Π΄Π΄ΠΎΠ½ Narrative](/docs/hollowengine/addons/narrative). Π‘Π΅Π· Π½Π΅Π³ΠΎ ΠΎΠΊΠ½ΠΎ Ρ‚Π°ΠΉΠΌΠ»Π°ΠΉΠ½Π° ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρ‚Π°ΠΉΠΌΠ»Π°ΠΉΠ½Ρ‹ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹Ρ… Ρ„Π°ΠΉΠ»ΠΎΠ², Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ ΠΌΠΎΠ½Ρ‚Π°ΠΆΠ΅ΠΉ ΠΈ эффСктов. +::: + ![Π’Π°ΠΉΠΌΠ»Π°ΠΉΠ½ ΠΈ ΠΎΠΊΠ½ΠΎ просмотра Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π° катсцСн](/hollowengine/pictures/cutscenes/overview.webp) --- diff --git a/gradle.properties b/gradle.properties index 51aa95a5e..abd7605c2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.parallel=true # Mod Options modId=hollowengine modName=HollowEngine -modVersion=2.5.3 +modVersion=2.6.0-beta modAuthor=HollowHorizon license=CC BY-NC-SA 4.0 modGroup=ru.hollowhorizon.hollowengine diff --git a/gradle/lang-merge.gradle b/gradle/lang-merge.gradle index f546c147b..038f74a57 100644 --- a/gradle/lang-merge.gradle +++ b/gradle/lang-merge.gradle @@ -3,9 +3,9 @@ import groovy.json.JsonSlurper import java.nio.charset.StandardCharsets -def modId = property("modId") -def splitLangDir = rootProject.file("runtime/src/main/lang") -def legacyLangDir = rootProject.file("runtime/src/main/resources/assets/${modId}/lang") +def modId = project.ext.has("langModId") ? project.ext.get("langModId") : property("modId") +def splitLangDir = project.file("src/main/lang") +def legacyLangDir = project.file("src/main/resources/assets/${modId}/lang") def mergedLangDir = layout.buildDirectory.dir("generated/lang/assets/${modId}/lang") def jsonSlurper = new JsonSlurper() @@ -87,7 +87,9 @@ tasks.register("mergeLang") { description = "Merges split and legacy lang files into standard locale json files." inputs.dir(splitLangDir) - inputs.dir(legacyLangDir) + if (legacyLangDir.exists()) { + inputs.dir(legacyLangDir) + } outputs.dir(mergedLangDir) doLast { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt index ef4e150c3..63081d44a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt @@ -31,6 +31,7 @@ import net.minecraft.client.sounds.ChannelAccess import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.core.NonNullList +import net.minecraft.nbt.CompoundTag import net.minecraft.network.chat.Component import net.minecraft.resources.FileToIdConverter import net.minecraft.resources.ResourceLocation @@ -67,6 +68,7 @@ import ru.hollowhorizon.hollowengine.bootstrap.runtime.EventBridge import ru.hollowhorizon.hollowengine.bootstrap.runtime.PathObstacles import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimeBridge import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimePlatform +import ru.hollowhorizon.hollowengine.client.camera.CameraDirector import ru.hollowhorizon.hollowengine.client.colliders.ClientColliderHooks import ru.hollowhorizon.hollowengine.client.colliders.ClientColliderPoses import ru.hollowhorizon.hollowengine.client.colliders.ColliderDebugRenderer @@ -87,7 +89,6 @@ import ru.hollowhorizon.hollowengine.client.sounds.SoundStreams import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeGameViewport import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlay import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlayPoint -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneCameraSystem import ru.hollowhorizon.hollowengine.client.ui.notification.NotificationOverlay import ru.hollowhorizon.hollowengine.client.ui.panels.UiPanels import ru.hollowhorizon.hollowengine.client.ui.script.UiScriptHudHost @@ -131,6 +132,7 @@ import ru.hollowhorizon.hollowengine.common.runtime.RuntimeAnnotationEnvironment import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScriptReload import ru.hollowhorizon.hollowengine.common.scripting.reload.ServerReloadScripts import ru.hollowhorizon.hollowengine.common.ui.HudPlacement +import ru.hollowhorizon.hollowengine.common.ui.ScreenLevel import ru.hollowhorizon.hollowengine.common.ui.hud.HudLayerRegistry import ru.hollowhorizon.hollowengine.common.utils.* import ru.hollowhorizon.hollowengine.fabric.internal.IrisHelper @@ -383,6 +385,23 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { partialTick: Float, ) { ScreenEvent.Render.Post.post(ScreenEvent.Render.Post(screen, guiGraphics, mouseX, mouseY, partialTick)) + UiScriptHudHost.renderOnScreen(screen, ScreenLevel.CONTENT, guiGraphics, System.nanoTime()) + } + + override fun onScreenFrameStart(screen: Screen) { + UiScriptHudHost.beginScreenFrame() + } + + override fun onScreenBackgroundRendered(screen: Screen, guiGraphics: GuiGraphics) { + UiScriptHudHost.renderOnScreen(screen, ScreenLevel.BACKGROUND, guiGraphics, System.nanoTime()) + } + + override fun onContainerTooltipPre(screen: Screen, guiGraphics: GuiGraphics) { + UiScriptHudHost.renderOnScreen(screen, ScreenLevel.CONTENT, guiGraphics, System.nanoTime()) + } + + override fun onScreenFrameEnd(screen: Screen, guiGraphics: GuiGraphics) { + UiScriptHudHost.renderOnScreen(screen, ScreenLevel.TOP, guiGraphics, System.nanoTime()) } override fun onRenderArm( @@ -568,7 +587,6 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun onClientRenderTickPre(client: Minecraft) { - CutsceneCameraSystem.update(client) RenderTickEvent.Pre.post(RenderTickEvent.Pre(client)) } @@ -611,11 +629,12 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { AttachmentRegistry.close(level) } - override fun onEntitySaved(entity: Entity, tag: net.minecraft.nbt.CompoundTag) { + override fun onEntitySaved(entity: Entity, tag: CompoundTag) { + EntityEvent.Saving.post(EntityEvent.Saving(entity)) AttachmentRegistry.saveEntity(entity, tag) } - override fun onEntityLoaded(entity: Entity, tag: net.minecraft.nbt.CompoundTag) { + override fun onEntityLoaded(entity: Entity, tag: CompoundTag) { AttachmentRegistry.loadEntity(entity, tag) } @@ -781,7 +800,7 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { roll: Float, partialTick: Float, ): RuntimeBridge.CameraSetup { - val pose = CutsceneCameraSystem.currentPose.takeIf { shouldApplyCutsceneCamera() } + val pose = CameraDirector.currentPose.takeIf { shouldApplyCameraPose() } val event = CameraSetupEvent( gameRenderer = gameRenderer, camera = camera, @@ -795,8 +814,8 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun getCameraOverride(partialTick: Float): RuntimeBridge.CameraOverride { - if (!shouldApplyCutsceneCamera()) return RuntimeBridge.CameraOverride.NONE - val pose = CutsceneCameraSystem.currentPose ?: return RuntimeBridge.CameraOverride.NONE + if (!shouldApplyCameraPose()) return RuntimeBridge.CameraOverride.NONE + val pose = CameraDirector.currentPose ?: return RuntimeBridge.CameraOverride.NONE return RuntimeBridge.CameraOverride( true, pose.position.x.toDouble(), @@ -821,13 +840,13 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { camera = camera, partialTick = partialTick, changingFov = changingFov, - fov = CutsceneCameraSystem.currentPose?.takeIf { shouldApplyCutsceneCamera() }?.fov?.toDouble() ?: fov, + fov = CameraDirector.currentPose?.takeIf { shouldApplyCameraPose() }?.fov?.toDouble() ?: fov, ) CameraFovEvent.post(event) return event.fov } - private fun shouldApplyCutsceneCamera(): Boolean { + private fun shouldApplyCameraPose(): Boolean { return !IrisHelper.isShadowRendering() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt index b51c66a35..aa3e3149a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HighlightTheme.kt @@ -32,6 +32,14 @@ object HighlightTheme { return hsvToArgb(hue, DslMarkerSaturation, DslMarkerValue) } + /** + * The color of slot [index] of a set of things told apart by color. Each slot turns the hue by the golden angle, + * so neighboring slots are always far apart, however many there are. + */ + fun paletteArgb(index: Int): Int = hsvToArgb((index * GoldenAngle) % 360f, PaletteSaturation, DslMarkerValue) + + private const val GoldenAngle = 137.50776f + private const val PaletteSaturation = 0.6f private const val DslMarkerSaturation = 0.45f private const val DslMarkerValue = 0.88f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt new file mode 100644 index 000000000..196cb3096 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraDirector.kt @@ -0,0 +1,61 @@ +package ru.hollowhorizon.hollowengine.client.camera + +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.common.events.ClientOnly +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderItemInHandEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderOverlayEvent +import ru.hollowhorizon.hollowengine.common.utils.rl + +/** + * Something that wants to point the camera, such as a cutscene or a dialogue's own camera. + */ +interface CameraPoseSource { + /** Where the camera should be, or null while this source leaves it alone. */ + val currentPose: CameraPose? + + /** Whether the hand is hidden while this source holds the camera. */ + val hidesHand: Boolean get() = false + + /** Whether screen overlays such as the vignette are hidden while this source holds the camera. */ + val hidesOverlays: Boolean get() = false +} + +/** + * Decides where the camera is. The render bridge reads [currentPose] alone, so every source reaches the + * screen through the same path. + */ +@ClientOnly +object CameraDirector { + /** + * Sources addons contribute. The one with the highest priority that has a pose wins, so a cutscene + * registers above a dialogue camera. + */ + val sources = ExtensionPoints.create("hollowengine:camera/pose-sources".rl, CameraPoseSource::class) + + /** + * A pose held by a tool that needs a frame seen from a given point. It wins over every source while + * it is set and hides the hand. + */ + @Volatile + var heldPose: CameraPose? = null + + val currentPose: CameraPose? + get() = heldPose ?: activeSource?.currentPose + + fun register(id: String, source: CameraPoseSource): ExtensionHandle = sources.register(id.rl, source) + + private val activeSource: CameraPoseSource? + get() = sources.extensions.firstOrNull { it.currentPose != null } + + @SubscribeEvent + fun onRenderOverlays(event: RenderOverlayEvent.Pre) { + if (activeSource?.hidesOverlays == true) event.isCanceled = true + } + + @SubscribeEvent + fun onRenderHand(event: RenderItemInHandEvent) { + if (heldPose != null || activeSource?.hidesHand == true) event.isCanceled = true + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt new file mode 100644 index 000000000..d777c1d0c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/camera/CameraPose.kt @@ -0,0 +1,14 @@ +package ru.hollowhorizon.hollowengine.client.camera + +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f + +/** Where the camera stands and looks: [rotation] is pitch, yaw and roll in degrees, as F3 shows them. */ +data class CameraPose( + val position: Vec3f = Vec3f.ZERO, + val rotation: Vec3f = Vec3f.ZERO, + val fov: Float = 70f, +) { + val pitch: Float get() = rotation.x + val yaw: Float get() = rotation.y + val roll: Float get() = rotation.z +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/DialogueScreenUi.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/DialogueScreenUi.kt deleted file mode 100644 index c16b746de..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/dialogue/DialogueScreenUi.kt +++ /dev/null @@ -1,201 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.dialogue - -import androidx.compose.runtime.* -import org.lwjgl.glfw.GLFW -import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState -import ru.hollowhorizon.hollowengine.common.dialogue.* -import ru.hollowhorizon.hollowengine.common.ui.UiGuiScale -import ru.hollowhorizon.hollowengine.common.ui.UiScope -import ru.hollowhorizon.hollowengine.common.ui.UiScreenDefinition -import ru.hollowhorizon.hollowengine.common.ui.send - -/** - * The dialogue window the engine ships with. - */ -object DialogueScreenUi { - const val STYLESHEET = "hollowengine:ui/styles/dialogue.hss" - const val EXIT_DURATION_MILLIS = 760L - - /** Registers the built-in screen. Scripts declaring the same id take over from it. */ - fun register(registry: (UiScreenDefinition) -> Unit) { - registry( - UiScreenDefinition( - id = UiDialoguePresentation.DEFAULT_SCREEN, - title = "Dialogue", - closeOnEscape = false, - pausesGame = false, - rebuildEveryFrame = false, - guiScale = UiGuiScale.Auto, - exitDuration = EXIT_DURATION_MILLIS, - content = { DialogueScreen() }, - ), - ) - } -} - -private const val CURSOR_TEXTURE = "hollowengine:textures/gui/dialogues/cursor.png" -private const val DESC_TEXTURE = "hollowengine:textures/gui/dialogues/desc.png" - -/** `icon="none"` on a `@choice` drops the icon that otherwise sits left of the button. */ -private const val NO_ICON = "none" - -/** Beyond this the appearance stagger stops growing, or a long menu would crawl in. */ -private const val MAX_STAGGERED_CHOICE = 7 - -/** `#dialogue-continue:ready`, the line is finished and waiting to be advanced. */ -private val READY_STATE = UiState.of("ready") - -private val ADVANCE_KEYS = intArrayOf( - GLFW.GLFW_KEY_ENTER, - GLFW.GLFW_KEY_KP_ENTER, - GLFW.GLFW_KEY_SPACE, - GLFW.GLFW_KEY_TAB, -) - -@Composable -private fun UiScope.DialogueScreen() { - val line = data[DialogueUiKeys.Line] - val choices = data[DialogueUiKeys.Choices] - - val root = Modifier - .style(DialogueScreenUi.STYLESHEET) - .focusScope() - .input(clickable = true) - .onClick { advance() } - .onKeyInput { input -> - if (input.key in ADVANCE_KEYS) { - input.consume() - advance() - } - } - - Box(id = "dialogue-root", mode = UiBoxMode.FREE, modifier = root) { - Box(id = "dialogue-dim") - if (choices.options.isNotEmpty()) ChoiceList(choices) - if (line.text.isNotEmpty()) DialogueBox(line, showContinue = choices.options.isEmpty()) - } -} - -@Composable -private fun DialogueBox(line: DialogueLineView, showContinue: Boolean) { - Column(id = "dialogue-box") { - if (line.speaker.isNotEmpty()) { - Box(id = "dialogue-name") { Text(line.speaker, tags = listOf("dialogue-name-text")) } - } - Box(id = "dialogue-body") { - TypedText(line) - - val ready = showContinue && line.awaiting - Image( - CURSOR_TEXTURE, - id = "dialogue-continue", - modifier = if (ready) Modifier.state(READY_STATE) else null, - ) - } - } -} - -@Composable -private fun TypedText(line: DialogueLineView) { - val reveal = remember(line.id) { mutableStateOf(0) } - val textLength = line.visibleLength - - LaunchedEffect(line.id, textLength, line.charDelay) { - if (line.charDelay <= 0) { - reveal.value = textLength - return@LaunchedEffect - } - val from = reveal.value - var startNanos = -1L - while (reveal.value < textLength) { - withFrameNanos { now -> - if (startNanos < 0L) startNanos = now - val elapsedMillis = (now - startNanos) / 1_000_000L - reveal.value = (from + elapsedMillis / line.charDelay).toInt().coerceAtMost(textLength) - } - } - } - - LaunchedEffect(line.id, line.skips) { - if (line.skips > 0) reveal.value = textLength - } - - FormattedText( - value = line.text, - tags = listOf("dialogue-text"), - visibleCharacters = reveal.value.coerceIn(0, textLength), - pendingTags = listOf("dialogue-text-pending"), - ) -} - -@Composable -private fun UiScope.ChoiceList(choices: DialogueChoicesView) { - val scroll = rememberScrollState() - var viewportHeight by remember { mutableStateOf(0f) } - var firstHeight by remember { mutableStateOf(0f) } - var lastHeight by remember { mutableStateOf(0f) } - val lastIndex = choices.options.lastIndex - - fun edge(optionHeight: Float) = - if (viewportHeight <= 0f || optionHeight <= 0f) 0f else ((viewportHeight - optionHeight) / 2f).coerceAtLeast(0f) - - Box( - id = "choice-viewport", - modifier = Modifier - .scrollable(horizontal = false, hasVerticalScrollbar = false, state = scroll) - .input(draggable = true) - .onDrag { event -> scroll.scrollBy(deltaY = -event.deltaY) } - .onPlaced { rect -> viewportHeight = rect.height }, - ) { - Column( - id = "choice-list", - modifier = Modifier.padding(0.px, edge(firstHeight).px, 0.px, edge(lastHeight).px), - ) { - choices.options.forEach { option -> - Choice( - option = option, - choices = choices, - onMeasured = { height -> - if (option.index == 0) firstHeight = height - if (option.index == lastIndex) lastHeight = height - }, - ) - } - } - } -} - -@Composable -private fun UiScope.Choice(option: DialogueChoiceView, choices: DialogueChoicesView, onMeasured: (Float) -> Unit) { - val voted = choices.myVote >= 0 - val mine = choices.myVote == option.index - val won = choices.decided == option.index - val lost = choices.decided >= 0 && !won - - var modifier = Modifier.onPlaced { rect -> onMeasured(rect.height) } - if (voted && !mine) modifier = modifier.state(UiState.DISABLED) - if (mine || won) modifier = modifier.state(UiState.SELECTED) - if (won) modifier = modifier.state(UiState.of("won")) - if (lost) modifier = modifier.state(UiState.of("lost")) - if (!voted && choices.decided < 0) { - modifier = modifier.input(hoverable = true, clickable = true) - .onClick { choose(option.index) } - } - - val order = "choice-${option.index.coerceIn(0, MAX_STAGGERED_CHOICE)}" - Row(tags = listOf("choice", order), modifier = modifier) { - if (!option.icon.equals(NO_ICON, ignoreCase = true)) { - Image(option.icon.ifEmpty { DESC_TEXTURE }, tags = listOf("choice-desc")) - } - Box(tags = listOf("choice-button")) { FormattedText(option.text, tags = listOf("choice-text")) } - Image(CURSOR_TEXTURE, tags = listOf("choice-cursor")) - } -} - -private fun UiScope.advance() = send { putString(DialogueUiKeys.Action, DialogueUiKeys.AdvanceAction) } - -private fun UiScope.choose(index: Int) = send { - putString(DialogueUiKeys.Action, DialogueUiKeys.ChooseAction) - putInt(DialogueUiKeys.Index, index) -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GameAnimationClips.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GameAnimationClips.kt new file mode 100644 index 000000000..b71deeffa --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GameAnimationClips.kt @@ -0,0 +1,24 @@ +package ru.hollowhorizon.hollowengine.client.editor + +import net.minecraft.client.Minecraft +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.client.models.internal.Model +import ru.hollowhorizon.hollowengine.client.models.internal.manager.HollowModelManager +import ru.hollowhorizon.hollowengine.common.scripting.ide.AnimationClips + +/** + * The clips of the models the client has loaded, for the analysis thread. What is not known yet is asked for, and the + * next analysis sees the answer. + */ +object GameAnimationClips : AnimationClips { + override fun of(model: String): List? { + val location = ResourceLocation.tryParse(model) ?: return null + if (!HollowModelManager.supports(location)) return null + return HollowModelManager.getOrCreate(location).value.takeIf { it !== Model.EMPTY }?.animations?.map { it.name } + } + + override fun modelOfNpc(id: String): String? { + Minecraft.getInstance().execute { WorldNpcModels.of(id) } + return WorldNpcModels.known(id)?.model + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt index 1c01fde85..8a237398b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoKeyboardTransform.kt @@ -1,5 +1,8 @@ package ru.hollowhorizon.hollowengine.client.editor +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import net.minecraft.world.phys.Vec3 import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys @@ -21,6 +24,10 @@ enum class GizmoKeyResult { CANCELLED, } +enum class GizmoRotationMode { + VIEW_ALIGNED, TRACKBALL, +} + /** * A transform started from the keyboard, the way Blender does it: G, R or S begins it where the pointer * is, the pointer drives it, X, Y or Z holds it to an axis (of the orientation the gizmo is set to first, of @@ -43,6 +50,11 @@ class GizmoKeyboardTransform( var orientation: GizmoOrientation? = null private set + var rotationMode by mutableStateOf(GizmoRotationMode.VIEW_ALIGNED) + private set + + private val trackball = manipulator.trackball(start, startX, startY) + private val primary = if (mode == GizmoEditMode.SCALE) GizmoOrientation.LOCAL else GizmoSettings.orientation private lateinit var handle: GizmoHandle @@ -53,11 +65,24 @@ class GizmoKeyboardTransform( } /** Where the pointer at ([x], [y]) takes the object, or null while it cannot tell. */ - fun update(x: Float, y: Float, modifiers: Int): GizmoTransformValues? = manipulator.update(drag, x, y, modifiers) + fun update(x: Float, y: Float, modifiers: Int): GizmoTransformValues? = + if (mode == GizmoEditMode.ROTATE && rotationMode == GizmoRotationMode.TRACKBALL) { + trackball.update(x, y, modifiers) + } else manipulator.update(drag, x, y, modifiers) fun key(key: Int, modifiers: Int): GizmoKeyResult { val axis = AXIS_ACTIONS.indexOfFirst { it.matches(key, modifiers) } return when { + mode == GizmoEditMode.ROTATE && HollowKeys.ROTATE.matches(key, modifiers) -> { + rotationMode = if (rotationMode == GizmoRotationMode.VIEW_ALIGNED) { + GizmoRotationMode.TRACKBALL + } else GizmoRotationMode.VIEW_ALIGNED + this.axis = null + orientation = null + restart() + GizmoKeyResult.CHANGED + } + axis >= 0 -> { constrain(axis) GizmoKeyResult.CHANGED @@ -74,6 +99,7 @@ class GizmoKeyboardTransform( /** The line of the axis it is held to, and the angle swept while rotating. */ fun draw(scope: UiCanvasDrawScope) { + if (mode == GizmoEditMode.ROTATE && rotationMode == GizmoRotationMode.TRACKBALL) return GizmoRenderer.drawRotationSector(scope, geometry, projector, drag) GizmoRenderer.drawHandles(scope, listOf(handle), null, null) } @@ -83,6 +109,7 @@ class GizmoKeyboardTransform( * over from where it began, so motion along the old axis does not carry over. */ private fun constrain(index: Int) { + rotationMode = GizmoRotationMode.VIEW_ALIGNED when { axis != index -> { axis = index diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt index ecb026058..f3e0824a8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoManipulator.kt @@ -97,6 +97,9 @@ class GizmoManipulator( private val projector: GizmoProjector, private val snapping: GizmoSnapping = GizmoSnapping.WORLD, ) { + internal fun trackball(start: GizmoTransformValues, x: Float, y: Float) = + GizmoTrackballRotation(projector, start, x, y, snapping) + fun begin(handle: GizmoHandle, values: GizmoTransformValues, pointerX: Float, pointerY: Float): GizmoDrag { val axis = handle.worldAxis?.normalize() val drag = GizmoDrag(handle.id, handle.worldOrigin, axis, values) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoSession.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoSession.kt index 55dfd543e..6f9465984 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoSession.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoSession.kt @@ -128,6 +128,16 @@ class GizmoSession(val projector: GizmoProjector, snapping: GizmoSnapping = Gizm */ fun key(target: GizmoTarget?, key: Int, modifiers: Int, x: Float, y: Float): Boolean { keyboard?.let { transform -> + val mode = GizmoKeyboardTransform.modeFor(key, modifiers) + if (mode != null && mode != transform.mode) { + val held = held ?: return false + if (!held.canEdit(mode)) return true + held.restore() + shiftX = 0f + shiftY = 0f + keyboard = GizmoKeyboardTransform(mode, transform.start, x, y, projector, geometry, manipulator) + return true + } val result = transform.key(key, modifiers) if (result == GizmoKeyResult.CHANGED) { held?.restore() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt new file mode 100644 index 000000000..464e41b58 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotation.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.client.editor + +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.common.utils.math.QuatF +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.utils.math.deg +import kotlin.math.hypot +import kotlin.math.round + +/** Free rotation about the camera's right and up axes, measured from the original pointer position. */ +internal class GizmoTrackballRotation( + projector: GizmoProjector, + private val start: GizmoTransformValues, + private val startX: Float, + private val startY: Float, + private val snapping: GizmoSnapping, +) { + private val axes = projector.viewAxes() + private val degreesPerPixel = 360f / minOf(projector.width, projector.height) + + fun update(x: Float, y: Float, modifiers: Int): GizmoTransformValues? { + val (right, up) = axes ?: return null + val dx = x - startX + val dy = y - startY + val distance = hypot(dx, dy) + if (distance <= 1e-6f) return start + val axis = up.scale(dx.toDouble()).add(right.scale(dy.toDouble())).normalize() + val speed = if (modifiers and GLFW.GLFW_MOD_SHIFT != 0) 0.1f else 1f + var angle = distance * degreesPerPixel * speed + GizmoStep.of(modifiers)?.let { step -> + val tick = snapping.rotation(step).toFloat() + angle = round(angle / tick) * tick + } + val rotation = QuatF(angle.deg, Vec3f(axis.x.toFloat(), axis.y.toFloat(), axis.z.toFloat())) + return start.copy(rotation = (rotation * start.rotation).normed()) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldNpcModels.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldNpcModels.kt new file mode 100644 index 000000000..1da095e03 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldNpcModels.kt @@ -0,0 +1,41 @@ +package ru.hollowhorizon.hollowengine.client.editor + +import androidx.compose.runtime.mutableStateMapOf +import net.minecraft.client.Minecraft +import net.minecraft.client.multiplayer.ClientLevel +import ru.hollowhorizon.hollowengine.common.npcs.RequestNpcModelPacket + +data class WorldNpcModel(val alive: Boolean, val model: String?) + +object WorldNpcModels { + private const val STALE_MILLIS = 10_000L + + private val known = mutableStateMapOf() + private val asked = HashMap() + private var level: ClientLevel? = null + + fun of(id: String): WorldNpcModel? { + val current = Minecraft.getInstance().level ?: return null + if (current !== level) forget(current) + val now = System.currentTimeMillis() + if (now - (asked[id] ?: (Long.MIN_VALUE / 2)) > STALE_MILLIS) { + asked[id] = now + RequestNpcModelPacket(id).send() + } + return known[id] + } + + /** What the server last said of [id], without asking again; safe to read from any thread. */ + fun known(id: String): WorldNpcModel? = known[id] + + internal fun answer(id: String, alive: Boolean, model: String?) { + val answer = WorldNpcModel(alive, model) + if (known[id] != answer) known[id] = answer + } + + private fun forget(current: ClientLevel) { + level = current + asked.clear() + known.clear() + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/history/UndoHistory.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/history/UndoHistory.kt index bbb9fd474..8ba7b8044 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/history/UndoHistory.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/history/UndoHistory.kt @@ -86,6 +86,14 @@ class UndoHistory( if (!step.isEmpty) push(step) } + /** Ends the open transaction, however deep it is, taking back all it recorded; none of it stays in the history. */ + fun rollback() { + val open = transaction ?: return + transaction = null + lastMergeKey = null + (open.steps + open.attached).asReversed().forEach { it.undo() } + } + inline fun transaction(label: UndoLabel? = null, block: () -> R): R { begin(label) try { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt index c1fc20c4a..3911ffca7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/keymap/HollowKeys.kt @@ -70,6 +70,7 @@ object HollowKeys { val NEXT_KEY = action("timeline.next_key", TIMELINE, key(GLFW.GLFW_KEY_UP)) val PREVIOUS_KEY = action("timeline.previous_key", TIMELINE, key(GLFW.GLFW_KEY_DOWN)) val SMOOTH_KEYS = action("timeline.smooth_keys", TIMELINE) + val SELECT_KEY_COLUMNS = action("timeline.select_key_columns", TIMELINE, key(GLFW.GLFW_KEY_K)) val NUDGE_LEFT = action("timeline.nudge_left", TIMELINE, key(GLFW.GLFW_KEY_LEFT)) val NUDGE_RIGHT = action("timeline.nudge_right", TIMELINE, key(GLFW.GLFW_KEY_RIGHT)) val NUDGE_LEFT_FAR = action("timeline.nudge_left_far", TIMELINE, key(GLFW.GLFW_KEY_LEFT, SHIFT)) @@ -105,13 +106,17 @@ object HollowKeys { val ORBIT_UP = action("viewport.orbit_up", VIEWPORT, key(GLFW.GLFW_KEY_KP_8)) val ORBIT_DOWN = action("viewport.orbit_down", VIEWPORT, key(GLFW.GLFW_KEY_KP_2)) val VIEW_SELECTED = action("viewport.view_selected", VIEWPORT, key(GLFW.GLFW_KEY_KP_DECIMAL)) - val ORBIT_PIVOT = action("viewport.orbit_pivot", VIEWPORT) // Node Graphs - val ADD_NODE = action("graph.add_node", GRAPH, key(GLFW.GLFW_KEY_INSERT), key(GLFW.GLFW_KEY_N, CONTROL)) + val ADD_NODE = action( + "graph.add_node", GRAPH, + key(GLFW.GLFW_KEY_A, SHIFT), key(GLFW.GLFW_KEY_INSERT), key(GLFW.GLFW_KEY_N, CONTROL), + ) val GROUP = action("graph.group", GRAPH, key(GLFW.GLFW_KEY_G, CONTROL)) val UNGROUP = action("graph.ungroup", GRAPH, key(GLFW.GLFW_KEY_G, CONTROL or SHIFT)) val ENTRY_STATE = action("graph.entry_state", GRAPH, key(GLFW.GLFW_KEY_E)) + val NEXT_FOUND = action("graph.next_found", GRAPH, key(GLFW.GLFW_KEY_DOWN), key(GLFW.GLFW_KEY_F3)) + val PREVIOUS_FOUND = action("graph.previous_found", GRAPH, key(GLFW.GLFW_KEY_UP), key(GLFW.GLFW_KEY_F3, SHIFT)) fun init() = Unit diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompiler.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompiler.kt index 0c80c4ba0..4ab173dc3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompiler.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompiler.kt @@ -4,6 +4,7 @@ package ru.hollowhorizon.hollowengine.client.shadergraph enum class ShaderProblem { UNKNOWN_NODE, UNKNOWN_PIN, + MISSING_NODE, NO_OUTPUT_NODE, SECOND_OUTPUT_NODE, CYCLE, @@ -270,6 +271,8 @@ private class ResolvedGraph(private val graph: ShaderGraph, private val values: } } graph.links.forEach { link -> + if (graph.node(link.from) == null) diagnostics += ShaderDiagnostic(ShaderProblem.MISSING_NODE, link.to, link.from) + if (graph.node(link.to) == null) diagnostics += ShaderDiagnostic(ShaderProblem.MISSING_NODE, link.from, link.to) val from = kinds[link.from] val to = graph.node(link.to)?.let { node -> kinds[node.id]?.let { node to it } } if (from != null && from.output(link.output) == null) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEdits.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEdits.kt index c4f0cb1ac..0150be2f9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEdits.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEdits.kt @@ -17,8 +17,10 @@ fun ShaderGraph.withNode(node: ShaderGraphNode): ShaderGraph = copy(nodes = node */ fun ShaderGraph.withoutNodes(ids: Set): ShaderGraph { fun sourceOf(reroute: String): ShaderGraphLink? { + val seen = hashSetOf(reroute) var link = linkInto(reroute, ShaderNodeLibrary.REROUTE_INPUT) ?: return null while (link.from in ids && node(link.from)?.type == ShaderNodeLibrary.REROUTE) { + if (!seen.add(link.from)) return null link = linkInto(link.from, ShaderNodeLibrary.REROUTE_INPUT) ?: return null } return link.takeIf { it.from !in ids } @@ -34,12 +36,12 @@ fun ShaderGraph.withoutNodes(ids: Set): ShaderGraph { } /** - * A reroute [id] at ([x], [y]) put into the link at [index]: what fed the link feeds the reroute, and - * the reroute feeds where the link went. + * A reroute [id] at ([x], [y]) put into [link]: what fed the link feeds the reroute, and the reroute + * feeds where the link went. */ -fun ShaderGraph.withReroute(index: Int, id: String, x: Float, y: Float): ShaderGraph { - val link = links.getOrNull(index) ?: return this - return withoutLinkAt(index).withNode(ShaderGraphNode(id, ShaderNodeLibrary.REROUTE, x, y)) +fun ShaderGraph.withReroute(link: ShaderGraphLink, id: String, x: Float, y: Float): ShaderGraph { + if (link !in links) return this + return withoutLink(link).withNode(ShaderGraphNode(id, ShaderNodeLibrary.REROUTE, x, y)) .withLink(link.from, link.output, id, ShaderNodeLibrary.REROUTE_INPUT) .withLink(id, ShaderNodeLibrary.REROUTE_OUTPUT, link.to, link.input) } @@ -117,8 +119,7 @@ fun ShaderGraph.withLink(from: String, output: String, to: String, input: String fun ShaderGraph.withoutLinkInto(node: String, input: String): ShaderGraph = copy(links = links.filterNot { it.to == node && it.input == input }) -fun ShaderGraph.withoutLinkAt(index: Int): ShaderGraph = - if (index !in links.indices) this else copy(links = links.filterIndexed { at, _ -> at != index }) +fun ShaderGraph.withoutLink(link: ShaderGraphLink): ShaderGraph = copy(links = links - link) /** With [values] for the unconnected input [input] of the node. */ fun ShaderGraph.withValue(node: String, input: String, values: List): ShaderGraph = diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ClientSounds.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ClientSounds.kt index cdbe054fb..5e89155c2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ClientSounds.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ClientSounds.kt @@ -27,13 +27,14 @@ object ClientSounds { val start: Double, val timeoutSeconds: Float, val paused: Boolean, + val pauseWithGame: Boolean, val onEnd: ((Boolean) -> Unit)?, ) private val replays = LinkedHashMap() private var lastFrame = 0L - /** Whether the sound engine holds every channel, as under the pause menu; nothing moves on then. */ + /** Whether Minecraft holds gameplay channels. UI playback may opt out of this pause. */ private var engineHeld = false fun enginePaused(paused: Boolean) { @@ -55,12 +56,25 @@ object ClientSounds { timeoutSeconds: Float = DEFAULT_TIMEOUT, start: Double = 0.0, onEnd: ((playedThrough: Boolean) -> Unit)? = null, + ): ManagedSound = play(source, settings, emitter, id, timeoutSeconds, start, pauseWithGame = true, onEnd = onEnd) + + /** [pauseWithGame] is false for UI previews, whose playback must continue under the pause menu. */ + fun play( + source: SoundSource, + settings: SoundPlayback = SoundPlayback(), + emitter: SoundEmitter = SoundEmitter { SoundEmitterState.Listener }, + id: String = UUID.randomUUID().toString(), + timeoutSeconds: Float = DEFAULT_TIMEOUT, + start: Double = 0.0, + pauseWithGame: Boolean, + onEnd: ((playedThrough: Boolean) -> Unit)? = null, ): ManagedSound { check(Minecraft.getInstance().isSameThread) { "Play sounds on the client thread" } require(timeoutSeconds.isFinite() && timeoutSeconds >= 0f) replays.remove(id) sounds[id]?.let { playing -> if (start == 0.0 && playing.reconfigure(source, settings, emitter)) { + playing.configureGamePause(pauseWithGame, engineHeld) onEnd?.let { playing.onEnd = it } return playing } @@ -69,7 +83,7 @@ object ClientSounds { replaced.onEnd = null replaced.close() } - val sound = ManagedSound(id, source, settings, emitter, start.coerceAtLeast(0.0)) + val sound = ManagedSound(id, source, settings, emitter, start.coerceAtLeast(0.0), pauseWithGame) sound.timeoutSeconds = timeoutSeconds sound.onEnd = onEnd sounds[id] = sound @@ -83,7 +97,17 @@ object ClientSounds { emitter: SoundEmitterSpec, id: String, onEnd: ((playedThrough: Boolean) -> Unit)? = null, - ): ManagedSound = play(source, settings, emitter.createEmitter(), id, emitter.timeoutSeconds(), onEnd = onEnd) + ): ManagedSound = play(source, settings, emitter, id, pauseWithGame = true, onEnd = onEnd) + + fun play( + source: SoundSource, + settings: SoundPlayback, + emitter: SoundEmitterSpec, + id: String, + pauseWithGame: Boolean, + onEnd: ((playedThrough: Boolean) -> Unit)? = null, + ): ManagedSound = play(source, settings, emitter.createEmitter(), id, emitter.timeoutSeconds(), + pauseWithGame = pauseWithGame, onEnd = onEnd) fun find(id: String): ManagedSound? = sounds[id] @@ -105,13 +129,14 @@ object ClientSounds { val playing = sounds[id] ?: return replays[id] = Replay( playing.input, playing.settings, playing.emitterNow(), seconds.coerceAtLeast(0.0), playing.timeoutSeconds, - playing.isPaused, playing.onEnd, + playing.isPaused, playing.pauseWithGame, playing.onEnd, ) playing.onEnd = null playing.close() } private fun start(sound: ManagedSound) { + if (!sound.updatesWhile(engineHeld)) return try { when (sound.refresh()) { SoundEmitterState.Listener, is SoundEmitterState.Available -> { @@ -128,8 +153,8 @@ object ClientSounds { /** * Called after SoundEngine.tick: native EOF, /stopsound and rejected plays release the same owner. It runs while - * the game is paused, as it is under the IDE, whose previews play on; only what waits to start waits for the - * sound engine to go on when the pause menu holds it. + * the game is paused, as it is under the IDE. Previews may start and seek then; gameplay sounds wait for the + * sound engine to resume. */ fun tick(paused: Boolean) { SoundAttachments.prune() @@ -137,15 +162,15 @@ object ClientSounds { val iterator = sounds.values.iterator() while (iterator.hasNext()) { val sound = iterator.next() - if (!sound.started && !sound.isStopped && !engineHeld) { - if (!paused) sound.waitSeconds += SECONDS_PER_TICK + if (!sound.started && !sound.isStopped && sound.updatesWhile(engineHeld)) { + if (!paused || !sound.pauseWithGame) sound.waitSeconds += SECONDS_PER_TICK start(sound) if (!sound.started && !sound.isStopped && sound.timeoutSeconds > 0f && sound.waitSeconds >= sound.timeoutSeconds) { sound.failure = TimeoutException("Timed out waiting for the sound emitter") sound.close() } } - val ended = sound.started && !manager.isActive(sound) + val ended = sound.started && (!manager.isActive(sound) || sound.channelStopped) if (sound.isStopped || ended) { val playedThrough = !sound.isStopped && sound.failure == null sound.failure?.let { HollowEngine.LOGGER.error("Sound '{}' failed", sound.id, it) } @@ -154,11 +179,11 @@ object ClientSounds { sound.onEnd?.let { report -> runCatching { report(playedThrough) } } } } - if (engineHeld) return - val pending = replays.toMap() - replays.clear() + val pending = replays.filterValues { !engineHeld || !it.pauseWithGame } pending.forEach { (id, replay) -> - val sound = play(replay.source, replay.settings, replay.emitter, id, replay.timeoutSeconds, replay.start, replay.onEnd) + replays.remove(id) + val sound = play(replay.source, replay.settings, replay.emitter, id, replay.timeoutSeconds, replay.start, + replay.pauseWithGame, replay.onEnd) if (replay.paused) sound.pause() } } @@ -176,11 +201,10 @@ object ClientSounds { val now = System.nanoTime() val seconds = if (lastFrame == 0L) 0f else ((now - lastFrame) / NANOS_PER_SECOND).coerceIn(0f, MAX_FRAME) lastFrame = now - if (engineHeld) return - SoundListener.update(seconds) + if (!engineHeld) SoundListener.update(seconds) channels.forEach { (instance, channel) -> if (instance !is ManagedSound || instance.isStopped) return@forEach - instance.frame(channel, executor, seconds) + instance.frame(channel, executor, seconds, engineHeld) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ManagedSound.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ManagedSound.kt index 4c638afb9..312a925f5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ManagedSound.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/ManagedSound.kt @@ -34,6 +34,7 @@ class ManagedSound internal constructor( settings: SoundPlayback, private var emitter: SoundEmitter, internal val start: Double = 0.0, + pauseWithGame: Boolean = true, ) : AbstractTickableSoundInstance( SoundEvent.createVariableRangeEvent(input.eventLocation()), settings.category, @@ -42,6 +43,9 @@ class ManagedSound internal constructor( var settings: SoundPlayback = settings private set + internal var pauseWithGame: Boolean = pauseWithGame + private set + private val streamId = "hollowengine:playback/${ClientSounds.nextToken()}".rl private var disposed = false private var resolved = false @@ -85,6 +89,9 @@ class ManagedSound internal constructor( private var channel: ChannelAccess.ChannelHandle? = null private var executor: Executor? = null + /** Native channels finish even while paused game ticks leave SoundEngine's instance map unchanged. */ + internal val channelStopped: Boolean get() = channel?.isStopped == true + init { relative = true attenuation = SoundInstance.Attenuation.NONE @@ -148,6 +155,13 @@ class ManagedSound internal constructor( isPaused = false } + internal fun configureGamePause(value: Boolean, enginePaused: Boolean) { + if (pauseWithGame == value) return + pauseWithGame = value + val paused = isPaused || enginePaused && value + channel?.execute { if (paused) it.pause() else it.unpause() } + } + /** * Takes [playback] and [emitter] as it plays; false when [source] or how the stream is read differs, and it has * to start over. A sound fading out to stop is kept on. @@ -239,13 +253,14 @@ class ManagedSound internal constructor( * Sets everything about [handle]'s channel this sound decides, once a frame and [seconds] after the last one, * on Minecraft's sound [executor]. */ - internal fun frame(handle: ChannelAccess.ChannelHandle, executor: Executor, seconds: Float) { + internal fun frame(handle: ChannelAccess.ChannelHandle, executor: Executor, seconds: Float, enginePaused: Boolean) { + if (!updatesWhile(enginePaused)) return channel = handle this.executor = executor refresh() if (disposed) return if (fadeGain() <= 0f) return close() - if (started && !isPaused) position += seconds * pitchPlayed() + advance(seconds, enginePaused) val volume = Mth.clamp(getVolume() * categoryVolume(), 0f, 1f) val pitch = pitchPlayed() @@ -274,6 +289,13 @@ class ManagedSound internal constructor( } } + internal fun updatesWhile(enginePaused: Boolean): Boolean = !disposed && (!enginePaused || !pauseWithGame) + + /** The preview cursor follows this clock, independently of simulation ticks and the world's pause. */ + internal fun advance(seconds: Float, enginePaused: Boolean) { + if (started && !isPaused && updatesWhile(enginePaused)) position += seconds * pitchPlayed() + } + private fun pitchPlayed(): Float = Mth.clamp(getPitch(), MIN_PITCH, MAX_PITCH) private fun categoryVolume(): Float { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/UiSounds.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/UiSounds.kt index 9091bbbdd..a8006f9c8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/UiSounds.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/sounds/UiSounds.kt @@ -3,6 +3,6 @@ package ru.hollowhorizon.hollowengine.client.sounds import ru.hollowhorizon.hollowengine.common.sounds.SoundSource object UiSounds { - fun connect() = ClientSounds.play(SoundSource.parse("hollowengine:ui/connect.wav")) - fun select() = ClientSounds.play(SoundSource.parse("hollowengine:ui/select.wav")) + fun connect() = ClientSounds.play(SoundSource.parse("hollowengine:ui/connect.wav"), pauseWithGame = false) + fun select() = ClientSounds.play(SoundSource.parse("hollowengine:ui/select.wav"), pauseWithGame = false) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt index 661c6e023..531b1ad66 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/FormattedText.kt @@ -3,10 +3,10 @@ package ru.hollowhorizon.hollowengine.client.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import ru.hollowhorizon.hollowengine.client.ui.text.* -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextAnimation -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextDocument -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextStyle +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextAnimation +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextDocument +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser +import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextStyle import ru.hollowhorizon.hollowengine.client.ui.text.Gradient as UiGradient /** diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt index 35ee1a5f8..e23b9479b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiInputController.kt @@ -18,7 +18,10 @@ class HollowUiInputController { private val focusByScope = LinkedHashMap() private val hoverChain = HashSet() + private val engagedNodes = HashSet() + private val statedNodes = ArrayList() private val runtimeStack = ArrayDeque() + private var closingApplied = false private var activeScope: UiNode? = null private fun primaryFocus(): UiNode? = activeScope?.let { focusByScope[it] } ?: focusByScope.values.firstOrNull() @@ -76,7 +79,9 @@ class HollowUiInputController { val active = activeNode val dragging = draggingNode val scope = activeScope - if (hovered == null && active == null && dragging == null && scope == null && focusByScope.isEmpty()) return + if (hovered.isUnder(root) && active.isUnder(root) && dragging.isUnder(root) && scope.isUnder(root) && + focusByScope.all { (focusScope, target) -> focusScope.isUnder(root) && target.isUnder(root) } + ) return val wantedIds = HashSet(8) val identityChecked = HashSet(8) @@ -388,6 +393,10 @@ class HollowUiInputController { */ private fun focusTargets(frame: HollowUiFrame): List { val set = LinkedHashSet() + activeScope?.takeIf { it in frame.nodes }?.let { scope -> + focusByScope[scope]?.takeIf { it in frame.nodes }?.let { set += it } + set += scope + } primaryFocus()?.takeIf { it in frame.nodes }?.let { set += it } focusByScope.values.forEach { if (it in frame.nodes) set += it } frame.nodes.forEach { if (it.resolvedSnapshot.focusScope) set += it } @@ -442,6 +451,7 @@ class HollowUiInputController { private fun updateFocus(frame: HollowUiFrame, node: UiNode, dispatch: (UiEvent) -> Boolean) { val scope = node.enclosingFocusScope() ?: return setFocus(frame, scope, node.focusTargetWithin(scope), dispatch) + activeScope = scope } /** Sets (or clears, when [target] is null) the focused target of [scope]; other scopes untouched. */ @@ -555,7 +565,8 @@ class HollowUiInputController { } } - private fun applyRuntimeStates(node: UiNode, closing: Boolean) { + /** Only the nodes the pointer or the focus is on hold states, so the whole tree is walked just to close it. */ + private fun applyRuntimeStates(root: UiNode, closing: Boolean) { hoverChain.clear() var ancestor = hoveredNode @@ -564,31 +575,54 @@ class HollowUiInputController { ancestor = ancestor.layoutState.parentNode } - val focusTargets = if (focusByScope.isEmpty()) emptySet() else HashSet(focusByScope.values) - - runtimeStack.clear() - runtimeStack.add(node) - while (runtimeStack.isNotEmpty()) { - val current = runtimeStack.removeLast() - val hover = current in hoverChain - val active = current === activeNode - val focus = focusTargets.isNotEmpty() && current in focusTargets - val dragging = current === draggingNode - if (!hover && !active && !focus && !dragging && !closing) { - current.setRuntimeStates(emptySet()) - } else { - val states = linkedSetOf() - if (hover) states += UiState.HOVER - if (active) states += UiState.ACTIVE - if (focus) states += UiState.FOCUS - if (dragging) states += UiState.DRAGGING - if (closing) states += UiState.CLOSING - current.setRuntimeStates(states) + engagedNodes.clear() + engagedNodes += hoverChain + activeNode?.let { engagedNodes += it } + draggingNode?.let { engagedNodes += it } + engagedNodes += focusByScope.values + + if (closing || closingApplied) { + runtimeStack.clear() + runtimeStack.add(root) + while (runtimeStack.isNotEmpty()) { + val current = runtimeStack.removeLast() + current.setRuntimeStates(runtimeStatesOf(current, closing)) + for (index in current.children.indices.reversed()) { + runtimeStack.add(current.children[index]) + } + } + } else { + for (node in statedNodes) { + if (node !in engagedNodes) node.setRuntimeStates(emptySet()) } - for (index in current.children.indices.reversed()) { - runtimeStack.add(current.children[index]) + for (node in engagedNodes) { + if (node !is ScrollbarNode && node !is ScrollbarThumbNode) node.setRuntimeStates(runtimeStatesOf(node, false)) } } + closingApplied = closing + statedNodes.clear() + statedNodes += engagedNodes + } + + private fun runtimeStatesOf(node: UiNode, closing: Boolean): Set { + val hover = node in hoverChain + val active = node === activeNode + val focus = focusByScope.containsValue(node) + val dragging = node === draggingNode + if (!hover && !active && !focus && !dragging && !closing) return emptySet() + val states = linkedSetOf() + if (hover) states += UiState.HOVER + if (active) states += UiState.ACTIVE + if (focus) states += UiState.FOCUS + if (dragging) states += UiState.DRAGGING + if (closing) states += UiState.CLOSING + return states + } + + private fun UiNode?.isUnder(root: UiNode): Boolean { + var current = this ?: return true + while (current !== root) current = current.layoutState.parentNode ?: return false + return true } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiRuntime.kt index 4ca79bf25..82d771c69 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiRuntime.kt @@ -6,6 +6,7 @@ import ru.hollowhorizon.hollowengine.client.ui.layout.UiLayoutNode import ru.hollowhorizon.hollowengine.client.ui.layout.UiLayoutPipeline import ru.hollowhorizon.hollowengine.client.ui.layout.UiLayoutResult import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.layout.shownRect import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollOffset import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollState @@ -25,6 +26,7 @@ data class HollowUiFrame( val nowMillis: Long = 0L, ) { internal var profile: UiProfileFrame? = null + internal var visibleHits = UiVisibleHitCache() private val nodesByIdentifier: Map by lazy { val index = HashMap(nodes.size * 2) @@ -38,21 +40,30 @@ data class HollowUiFrame( fun hitTest(x: Float, y: Float): UiHit? = UiHitTester().hitTest(root, layout, x, y) /** Whether visible, input-opaque UI geometry is under the point (blocks click-through). */ - fun hitsVisible(x: Float, y: Float): Boolean = UiHitTester().hitsVisible(root, layout, x, y) + fun hitsVisible(x: Float, y: Float): Boolean = + visibleHits.getOrPut(x, y) { UiHitTester().hitsVisible(root, layout, x, y) } /** The node the wheel scrolls: the deepest one under the point that has room to move. */ fun scrollTargetAt(x: Float, y: Float): UiNode? = wheelChainAt(x, y).scrollTarget() - /** Node that wheel scrolls. */ - internal fun List.scrollTarget(): UiNode? { + /** + * Node that wheel scrolls. A plain [vertical] notch leaves a node that only moves sideways to whatever moves + * up and down around it, the way a page keeps scrolling over a wide table. + */ + internal fun List.scrollTarget(vertical: Boolean = false): UiNode? { var fallback: UiNode? = null + var sideways: UiNode? = null for (node in this) { if (!node.resolvedSnapshot.scrollable) continue val range = layout[node].scrollRange - if (range.x > ScrollTargetRangeEpsilon || range.y > ScrollTargetRangeEpsilon) return node + if (range.y > ScrollTargetRangeEpsilon) return node + if (range.x > ScrollTargetRangeEpsilon) { + if (!vertical) return node + if (sideways == null) sideways = node + } if (fallback == null) fallback = node } - return fallback + return sideways ?: fallback } /** @@ -151,6 +162,22 @@ private sealed interface ScrollTargetTask { ) : ScrollTargetTask } +/** The last answer for a point, passed along while frames change nothing that could alter it. */ +internal class UiVisibleHitCache { + private var x = Float.NaN + private var y = Float.NaN + private var hit = false + + inline fun getOrPut(x: Float, y: Float, compute: () -> Boolean): Boolean { + if (x != this.x || y != this.y) { + hit = compute() + this.x = x + this.y = y + } + return hit + } +} + fun UiRect?.intersect(other: UiRect?): UiRect? { if (this == null) return other if (other == null) return this @@ -178,6 +205,7 @@ class HollowUiRuntime( private val layoutPipeline = UiLayoutPipeline() private val input = HollowUiInputController() private val placedBounds = WeakHashMap() + private val shownBounds = WeakHashMap() private val reportedTextLayouts = WeakHashMap() private val reportedStyles = WeakHashMap() private var lastLayout: UiLayoutResult? = null @@ -240,7 +268,7 @@ class HollowUiRuntime( } internal fun prepareFrame(nowMillis: Long) { - lastLayout?.let(::applyPendingScrollRequests) + applyPendingScrollRequests() scrollState.update(nowMillis) preparedAtMillis = nowMillis } @@ -263,7 +291,8 @@ class HollowUiRuntime( subtreeLayoutRevision = root.layoutState.subtreeLayoutRevision, ) - val layoutReused = lastLayout != null && layoutKey == lastLayoutKey + val previousLayout = lastLayout + val layoutReused = previousLayout != null && layoutKey == lastLayoutKey val layout = when { layoutReused -> { if (profile != null) profile.layoutReuses++ @@ -272,23 +301,37 @@ class HollowUiRuntime( else -> scrolledOnly(layoutKey)?.let { previous -> layoutPipeline.rescroll(root, previous, scrollState, profile) - } ?: layoutPipeline.compute(root, width, height, scrollState, profile) + } ?: layoutPipeline.compute(root, width, height, scrollState, profile, reusableLayout(root, layoutKey)) }.let { placed -> if (scrollState.takeSettled()) layoutPipeline.rescroll(root, placed, scrollState, profile) ?: placed else placed } lastLayout = layout lastLayoutKey = layoutKey - dispatchPlacementCallbacks(layout) - dispatchTextLayoutCallbacks(layout) - dispatchResolvedStyleCallbacks(layout) - publishScrollGeometry(layout) + val unchanged = resolver.lastResolveReused && layout === previousLayout + if (!unchanged) { + val observed = resolver.observedNodes + dispatchPlacementCallbacks(layout, observed) + dispatchTextLayoutCallbacks(layout, observed) + dispatchResolvedStyleCallbacks(observed) + publishScrollGeometry(layout, observed) + } return HollowUiFrame( root = root, nodes = nodes, layout = layout, nowMillis = nowMillis, - ).also { it.profile = profile } + ).also { frame -> + frame.profile = profile + if (unchanged) lastFrame?.let { frame.visibleHits = it.visibleHits } + } + } + + private fun reusableLayout(root: UiNode, next: FrameLayoutKey): UiLayoutResult? { + val previousKey = lastLayoutKey ?: return null + if (next.width != previousKey.width || next.height != previousKey.height) return null + if (next.scrollRevision != previousKey.scrollRevision) return null + return lastLayout?.takeIf { it.root === root } } private fun scrolledOnly(next: FrameLayoutKey): UiLayoutResult? { @@ -354,7 +397,8 @@ class HollowUiRuntime( val horizontalModifier = input.modifiers and GLFW.GLFW_MOD_SHIFT != 0 || input.modifiers == 0 && horizontalScrollModifierDown() val chain = frame.wheelChainAt(input.mouseX, input.mouseY) - val target = with(frame) { chain.scrollTarget() } ?: this.input.focusedScrollableNode(frame) + val vertical = !horizontalModifier && input.scrollX == 0f + val target = with(frame) { chain.scrollTarget(vertical) } ?: this.input.focusedScrollableNode(frame) for (node in chain) { if (node.listensToScroll()) { @@ -407,15 +451,16 @@ class HollowUiRuntime( * Drains the requests queued on each container's [UiScrollHandle] before layout runs, so the * bumped scroll revision forces a fresh pass that places content at the requested offset. */ - private fun applyPendingScrollRequests(layout: UiLayoutResult) { - for (node in layout.nodes.keys) { + private fun applyPendingScrollRequests() { + for (node in resolver.observedNodes) { scrollState.applyPendingRequests(node.scrollHandle() ?: continue) } } - private fun publishScrollGeometry(layout: UiLayoutResult) { - for ((node, layoutNode) in layout.nodes) { + private fun publishScrollGeometry(layout: UiLayoutResult, observed: List) { + for (node in observed) { val handle = node.scrollHandle() ?: continue + val layoutNode = layout.nodes[node] ?: continue handle.viewport = layoutNode.content handle.range = layoutNode.scrollRange handle.screenTransform = layoutNode.inputTransform @@ -429,21 +474,29 @@ class HollowUiRuntime( * when they changed since the last report. The change guard keeps a callback that writes state * (e.g. a popup anchor) from looping: once the bounds settle, it stops firing. */ - private fun dispatchPlacementCallbacks(layout: UiLayoutResult) { - for (node in layout.traversalOrder) { + private fun dispatchPlacementCallbacks(layout: UiLayoutResult, observed: List) { + for (node in observed) { + val placed = layout.nodes[node] ?: continue var rect: UiRect? = null - val modifiers = node.resolvedModifiers + val modifiers = node.resolvedReports for (index in modifiers.indices) { val modifier = modifiers[index] if (modifier is UiDropTargetModifier) { - val placed = layout[node] modifier.target.bounds = placed.rect modifier.target.clip = placed.outerClip ?: placed.clip continue } + if (modifier is OnShownModifier) { + val shown = placed.shownRect() + if (shownBounds[node] != shown) { + shownBounds[node] = shown + modifier.callback(shown) + } + continue + } if (modifier !is OnPlacedModifier) continue if (rect == null) { - val current = layout[node].rect + val current = placed.rect if (placedBounds[node] == current) continue placedBounds[node] = current rect = current @@ -454,10 +507,10 @@ class HollowUiRuntime( } /** After resolve, hands every [OnResolvedStyleModifier] its node's computed style, once per change. */ - private fun dispatchResolvedStyleCallbacks(layout: UiLayoutResult) { - for (node in layout.traversalOrder) { + private fun dispatchResolvedStyleCallbacks(observed: List) { + for (node in observed) { var style: UiComputedStyle? = null - val modifiers = node.resolvedModifiers + val modifiers = node.resolvedReports for (index in modifiers.indices) { val modifier = modifiers[index] if (modifier !is OnResolvedStyleModifier) continue @@ -472,15 +525,16 @@ class HollowUiRuntime( } } - private fun dispatchTextLayoutCallbacks(layout: UiLayoutResult) { - for (node in layout.traversalOrder) { + private fun dispatchTextLayoutCallbacks(layout: UiLayoutResult, observed: List) { + for (node in observed) { + val placed = layout.nodes[node] ?: continue var textLayout: UiTextLayout? = null - val modifiers = node.resolvedModifiers + val modifiers = node.resolvedReports for (index in modifiers.indices) { val modifier = modifiers[index] if (modifier !is OnTextLayoutModifier) continue if (textLayout == null) { - val current = layout[node].textLayout ?: break + val current = placed.textLayout ?: break if (reportedTextLayouts[node] == current) break reportedTextLayouts[node] = current textLayout = current diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/Modifier.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/Modifier.kt index f47b47787..d9da2ea29 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/Modifier.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/Modifier.kt @@ -179,6 +179,16 @@ const val TextFieldDefaultKeyPriority = -1000 */ data class OnPlacedModifier(val callback: (UiRect) -> Unit) : Modifier +/** Whether this modifier hears from layout once it is done. */ +internal fun Modifier.reportsLayout() = this is OnPlacedModifier || this is OnShownModifier || + this is UiDropTargetModifier || this is OnTextLayoutModifier || this is OnResolvedStyleModifier + +/** + * Like [OnPlacedModifier], with the bounds the node has on the screen: past the translation and the scale of what it is + * inside, which is what a popup anchored to it needs. + */ +data class OnShownModifier(val callback: (UiRect) -> Unit) : Modifier + /** * Observes the node's laid-out text after each layout pass (a [SpanNode]'s line layout or a text * field's own layout). The runtime invokes [callback] only when the layout changes, so a field can @@ -535,6 +545,8 @@ fun Modifier.onUnfocus(handler: (UiEvent) -> Unit) = this then EventModifier(UiE /** Reports this node's bounds in root coordinates after layout; see [OnPlacedModifier]. */ fun Modifier.onPlaced(callback: (UiRect) -> Unit) = this then OnPlacedModifier(callback) +fun Modifier.onShown(callback: (UiRect) -> Unit) = this then OnShownModifier(callback) + /** Reports this node's laid-out text after layout; see [OnTextLayoutModifier]. */ fun Modifier.onTextLayout(callback: (UiTextLayout) -> Unit) = this then OnTextLayoutModifier(callback) @@ -659,9 +671,9 @@ private fun Iterable.appendModifierStatesTo(result: MutableSet.containsModifierState(state: UiState): Boolean { - for (modifier in this) { - when (modifier) { +private fun List.containsModifierState(state: UiState): Boolean { + for (index in indices) { + when (val modifier = this[index]) { is StateModifier -> if (state in modifier.states) return true is RuntimeStateModifier -> if (state in modifier.states) return true is CompositeModifier -> if (modifier.values.containsModifierState(state)) return true diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/OverlayManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/OverlayManager.kt index 6ae52ce00..70b3fedd7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/OverlayManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/OverlayManager.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.* * [OverlayHost] reads [content] (and [layer] for ordering) to compose the popup out-of-line, on top of * everything. [key] is a stable identity so the host can `key()` each popup across list changes. */ -class PopupEntry(val key: Any) { +class PopupEntry(val key: Any, val id: String? = null) { var layer by mutableStateOf(0) var dismissOnOutside by mutableStateOf(true) @@ -45,6 +45,7 @@ class OverlayManager { * finishes the exit transition; immediate entries are removed on disposal. */ fun register(entry: PopupEntry): () -> Unit { + entry.id?.let { id -> popups.removeAll { it.exiting && it.id == id } } entry.exiting = false popups += entry return { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCanvas.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCanvas.kt index 6230dea3e..d9227073f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCanvas.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCanvas.kt @@ -280,8 +280,9 @@ internal class UiCommandCanvasScope( val scaleX = contentRect.width / viewBox.width.coerceAtLeast(0.0001f) val scaleY = contentRect.height / viewBox.height.coerceAtLeast(0.0001f) val effectScale = max(abs(scaleX), abs(scaleY)) - for (element in document.elements) { - val shape = SvgPathShape(element.path, viewBox) + for (index in document.elements.indices) { + val element = document.elements[index] + val shape = document.shapes[index] for (effect in element.filterEffects) { if (effect !is UiSvgFilterEffect.DropShadow) continue submitSvgShape( @@ -294,9 +295,10 @@ internal class UiCommandCanvasScope( ) } } - for (element in document.elements) { - if (element.paint == UiPaint.None) continue - val shape = SvgPathShape(element.path, viewBox) + for (index in document.elements.indices) { + val element = document.elements[index] + if (element.paint === UiPaint.None) continue + val shape = document.shapes[index] var blurRadius = 0f for (effect in element.filterEffects) { when (effect) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCompose.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCompose.kt index 1646e464f..7e0639169 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCompose.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCompose.kt @@ -14,7 +14,6 @@ import ru.hollowhorizon.hollowengine.client.ui.layout.invalidateLayout import ru.hollowhorizon.hollowengine.client.ui.style.* import ru.hollowhorizon.hollowengine.client.ui.text.Shadow import ru.hollowhorizon.hollowengine.client.ui.widgets.* -import ru.hollowhorizon.hollowengine.common.scripting.ide.CodeFold import kotlin.coroutines.CoroutineContext import kotlin.math.roundToInt @@ -144,15 +143,14 @@ fun Layout( ) { val modifiers = modifier.asList() val imports = modifier.styleReferences() - val scoped: HollowUiContent = - if (imports.isEmpty()) content - else { - { - CompositionLocalProvider(LocalStylesheets provides LocalStylesheets.current + imports) { - content() - } + val scoped: HollowUiContent = if (imports.isEmpty()) content + else { + { + CompositionLocalProvider(LocalStylesheets provides LocalStylesheets.current + imports) { + content() } } + } key(id) { ReusableComposeNode( factory = { BoxNode(id, measurePolicy, tags, modifiers, attributes) }, @@ -227,9 +225,11 @@ fun Caret( ) = Box( id = id, tags = tags, - modifier = Modifier - .animation(UiCaretBlinkKeyframes, UiCaretBlinkPeriodMillis, iterationCount = Float.POSITIVE_INFINITY) - .then(modifier ?: Modifier), + modifier = Modifier.animation( + UiCaretBlinkKeyframes, + UiCaretBlinkPeriodMillis, + iterationCount = Float.POSITIVE_INFINITY + ).then(modifier ?: Modifier), ) /** @@ -354,12 +354,9 @@ fun Slider( id = id, mode = UiBoxMode.STACK, tags = tags + "slider", - modifier = Modifier.size(120.px, 16.px) - .cursor(UiCursorShape.HAND) - .onPress { setFromPointer(it.localX, it.width) } - .onDrag { setFromPointer(it.localX, it.width) } - .onRelease { onValueCommit?.invoke(current) } - .then(modifier ?: Modifier), + modifier = Modifier.size(120.px, 16.px).cursor(UiCursorShape.HAND) + .onPress { setFromPointer(it.localX, it.width) }.onDrag { setFromPointer(it.localX, it.width) } + .onRelease { onValueCommit?.invoke(current) }.then(modifier ?: Modifier), ) { Row(modifier = Modifier.size(100.percent, 100.percent)) { Box(modifier = Modifier.size(SliderThumbRadius.px, 1.px)) @@ -380,8 +377,7 @@ fun Slider( Box( tags = listOf("slider-thumb"), modifier = Modifier.size(SliderThumbSize.px, SliderThumbSize.px).align(vertical = UiAlign.CENTER) - .position((fraction * 100f).percent - SliderThumbRadius.px, 0.px) - .background(UiColor.White) + .position((fraction * 100f).percent - SliderThumbRadius.px, 0.px).background(UiColor.White) .border(1.px, WidgetThumbBorderColor, radius = SliderThumbRadius), ) } @@ -418,8 +414,8 @@ fun Checkbox( onCheckedChange?.invoke(next) } } - val base = Modifier.cursor(UiCursorShape.HAND).onClick(toggle) - .let { if (current) it.state(UiState.SELECTED) else it } + val base = + Modifier.cursor(UiCursorShape.HAND).onClick(toggle).let { if (current) it.state(UiState.SELECTED) else it } val progress by animateFloatAsState(if (current) 1f else 0f, durationMillis = CheckboxToggleMillis) when (variant) { @@ -428,14 +424,12 @@ fun Checkbox( mode = UiBoxMode.STACK, tags = tags + "checkbox", modifier = base.size(28.px, 16.px).borderRadius(8f) - .background(CheckboxTrackColor.interpolate(WidgetAccentColor, progress)) - .then(modifier ?: Modifier), + .background(CheckboxTrackColor.interpolate(WidgetAccentColor, progress)).then(modifier ?: Modifier), ) { Box( tags = listOf("checkbox-mark"), modifier = Modifier.size(12.px, 12.px).align(vertical = UiAlign.CENTER) - .position((2f + 12f * progress).px, 0.px) - .background(UiColor.White).borderRadius(6f), + .position((2f + 12f * progress).px, 0.px).background(UiColor.White).borderRadius(6f), ) } @@ -453,8 +447,7 @@ fun Checkbox( if (progress > 0.001f) { Box( tags = listOf("checkbox-mark"), - modifier = Modifier.size(8.px, 8.px).align(UiAlign.CENTER, UiAlign.CENTER) - .opacity(progress) + modifier = Modifier.size(8.px, 8.px).align(UiAlign.CENTER, UiAlign.CENTER).opacity(progress) .background(UiColor.White).borderRadius(if (circle) 4f else 2f), ) } @@ -486,6 +479,7 @@ private sealed interface HssShadowResolution { data object Inherit : HssShadowResolution data class Override(val shadow: Shadow?) : HssShadowResolution } + private val WidgetAccentColor = UiColor(0.36f, 0.62f, 0.95f, 1f) private val WidgetThumbBorderColor = UiColor(0.06f, 0.07f, 0.08f, 0.45f) private val WidgetCheckboxBorderColor = UiColor(0.55f, 0.6f, 0.68f, 1f) @@ -494,43 +488,30 @@ private val WidgetCheckboxBorderColor = UiColor(0.55f, 0.6f, 0.68f, 1f) * Value/onChange convenience wrapper over [EditableTextField]: owns a [TextFieldState], mirrors * external [value] changes into it and reports edits back through [onChange]. The state (carets, * selection, undo history) lives for as long as the composition does. + * + * @param editing how typing reshapes code; null edits plain text with one caret. + * @param insight what the field offers on top of its text: highlighting, completion, problems, hints. + * @param view the gutter and indent guides of a code field; null shows the text alone. + * @param search the search hits to mark. */ @Composable fun TextField( value: String = "", + onChange: ((String) -> Unit)? = null, mode: UiTextFieldMode = UiTextFieldMode.SINGLE_LINE, filter: UiTextInputFilter = UiTextInputFilter.ANY, - multiCaret: Boolean = false, - syntaxHighlighter: UiSyntaxHighlighter? = null, - completionContributor: UiCompletionContributor? = null, - indentSize: Int? = null, - autoPairs: Boolean = false, readOnly: Boolean = false, - diagnostics: List = emptyList(), - searchMatches: List = emptyList(), - activeSearchMatch: IntRange? = null, - inlayHints: List = emptyList(), - inlayHintsProvider: UiInlayHintsProvider? = null, - inlayRevision: Long = 0L, - completionRevision: Long = 0L, - signatureHelpProvider: UiSignatureHelpProvider? = null, - hoverInfoProvider: UiHoverInfoProvider? = null, - codeInsightRevision: Long = completionRevision, placeholder: String = "", + editing: UiCodeEditing? = null, + insight: UiCodeInsight = UiCodeInsight(), + view: UiCodeView? = null, + search: UiTextSearch? = null, wrap: Boolean? = null, fontSize: Float = DefaultUiFontSize, fontFamily: String? = null, textShadow: Shadow? = Shadow(offsetX = 1f, offsetY = 1f), caretColor: UiColor? = null, selectionColor: UiColor? = null, - lineNumbers: Boolean = false, - folds: List = emptyList(), - gutterMarkers: List = emptyList(), - lineNumberColor: UiColor? = null, - indentGuides: Boolean = false, - indentGuideColor: UiColor? = null, - onChange: ((String) -> Unit)? = null, - onInlayAction: ((UiInlayAction) -> Unit)? = null, state: TextFieldState? = null, id: String? = null, tags: Iterable = emptyList(), @@ -544,9 +525,6 @@ fun TextField( multiline = multiline, readOnly = readOnly, filter = filter, - indentSize = indentSize, - autoPairs = autoPairs, - multiCaret = multiCaret, fontSize = fontSize, fontFamily = fontFamily, wrap = wrap ?: multiline, @@ -556,9 +534,10 @@ fun TextField( fieldState.multiline = multiline fieldState.readOnly = readOnly fieldState.filter = filter - fieldState.indentSize = indentSize - fieldState.autoPairs = autoPairs - fieldState.multiCaret = multiCaret + fieldState.indentSize = editing?.indentSize + fieldState.autoPairs = editing?.autoPairs == true + fieldState.syntax = editing?.syntax + fieldState.multiCaret = editing?.multiCaret == true fieldState.fontSize = fontSize var hssFontFamily by remember { mutableStateOf(null) } fieldState.fontFamily = fontFamily ?: hssFontFamily @@ -606,26 +585,10 @@ fun TextField( }, id = id, tags = tags, - syntaxHighlighter = syntaxHighlighter, - inlayHints = if (hssInlayHints) inlayHints else emptyList(), - inlayHintsProvider = inlayHintsProvider.takeIf { hssInlayHints }, - inlayRevision = inlayRevision, - onInlayAction = onInlayAction, - completionContributor = completionContributor, - completionRevision = completionRevision, - signatureHelpProvider = signatureHelpProvider, - hoverInfoProvider = hoverInfoProvider, - codeInsightRevision = codeInsightRevision, - diagnostics = diagnostics, - searchMatches = searchMatches, - activeSearchMatch = activeSearchMatch, + insight = if (hssInlayHints) insight else insight.copy(inlayHints = emptyList(), inlayHintsProvider = null), + view = view, + search = search, placeholder = placeholder, - lineNumbers = lineNumbers, - folds = folds, - gutterMarkers = gutterMarkers, - lineNumberColor = lineNumberColor ?: EditableFieldLineNumberColor, - indentGuides = indentGuides, - indentGuideColor = indentGuideColor ?: EditableFieldIndentGuideColor, attributes = attributes, ) } @@ -751,8 +714,8 @@ fun Popup( } val isModal = rememberUpdatedState(modal) val parentLayer = LocalPopupLayer.current - val entry = remember { - PopupEntry(Any()).apply { + val entry = remember(id) { + PopupEntry(Any(), id).apply { this.content = { PopupNodeEmitter( anchorBounds = anchor.value, @@ -809,13 +772,10 @@ private fun PopupNodeEmitter( } if (!visible) return val shown = appeared && !exiting - val appearance = if (!animated) Modifier else Modifier - .transition( + val appearance = if (!animated) Modifier else Modifier.transition( UiTransition(UiProps.Opacity.name, PopupAppearMillis, TransitionEasing.EASE_OUT), UiTransition(UiProps.Translate.name, PopupAppearMillis, TransitionEasing.EASE_OUT), - ) - .opacity(if (shown) 1f else 0f) - .translate(y = if (shown) 0f else -PopupAppearRise) + ).opacity(if (shown) 1f else 0f).translate(y = if (shown) 0f else -PopupAppearRise) val base = appearance.then(modifier ?: Modifier) .let { if (exiting) it.inputTransparent() else it.focusScope().input(hoverable = true) } @@ -855,15 +815,14 @@ fun OverlayHost() { if (manager.hasModal) { Box( tags = listOf("overlay-scrim"), - modifier = Modifier.size(100.percent, 100.percent).inputTransparent().style("hollowengine:ui/styles/widgets.hss"), + modifier = Modifier.size(100.percent, 100.percent).inputTransparent() + .style("hollowengine:ui/styles/widgets.hss"), ) } if (manager.hasDismissable) { Box( tags = listOf("overlay-dismiss"), - modifier = Modifier.size(100.percent, 100.percent) - .focusScope() - .onClick { manager.dismissAll() } + modifier = Modifier.size(100.percent, 100.percent).focusScope().onClick { manager.dismissAll() } .onKeyInput { input -> if (input.key == GLFW.GLFW_KEY_ESCAPE) { manager.dismissAll() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiDragAndDrop.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiDragAndDrop.kt index 894a4e31b..0d8bb8d15 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiDragAndDrop.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiDragAndDrop.kt @@ -42,6 +42,7 @@ class UiDragAndDropState { private set private val targets = mutableListOf() + private var activeTarget: UiDropTarget? = null fun begin(item: UiDragItem, x: Float, y: Float) { this.item = item @@ -56,7 +57,13 @@ class UiDragAndDropState { val target = targetAt(x, y) hoveredTargetId = target?.id canDrop = target?.accepts?.invoke(dragged) == true - if (canDrop) target?.onDragOver?.invoke(dragged, x, y) + val acceptedTarget = target.takeIf { canDrop } + if (activeTarget !== acceptedTarget) { + val previous = activeTarget + activeTarget = acceptedTarget + previous?.onDragExit?.invoke() + } + if (item === dragged && canDrop && activeTarget === acceptedTarget) target?.onDragOver?.invoke(dragged, x, y) } /** Hands the item to the target under the pointer; the drag always ends. */ @@ -68,9 +75,12 @@ class UiDragAndDropState { } fun cancel() { + val previous = activeTarget + activeTarget = null item = null hoveredTargetId = null canDrop = false + previous?.onDragExit?.invoke() } internal fun register(target: UiDropTarget): () -> Unit { @@ -81,8 +91,7 @@ class UiDragAndDropState { } } - private fun targetAt(x: Float, y: Float): UiDropTarget? = - targets.lastOrNull { it.contains(x, y) } + private fun targetAt(x: Float, y: Float): UiDropTarget? = targets.lastOrNull { it.contains(x, y) } } /** A registered drop area. Bounds and the ancestor clip are updated by the layout pass. */ @@ -92,10 +101,11 @@ internal class UiDropTarget( var clip: UiRect? = null, var accepts: (UiDragItem) -> Boolean = { true }, var onDragOver: (UiDragItem, Float, Float) -> Unit = { _, _, _ -> }, + var onDragExit: () -> Unit = {}, var onDrop: (UiDragItem, Float, Float) -> Boolean = { _, _, _ -> false }, ) { - fun contains(x: Float, y: Float): Boolean = bounds.width > 0f && bounds.height > 0f && - bounds.contains(x, y) && clip?.contains(x, y) != false + fun contains(x: Float, y: Float): Boolean = + bounds.width > 0f && bounds.height > 0f && bounds.contains(x, y) && clip?.contains(x, y) != false } internal data class UiDropTargetModifier(val target: UiDropTarget) : Modifier @@ -108,8 +118,7 @@ val LocalDragAndDrop = staticCompositionLocalOf { null } */ fun Modifier.dragSource(state: UiDragAndDropState?, item: () -> UiDragItem?): Modifier { if (state == null) return this - return input(draggable = true) - .onDrag { event -> + return input(draggable = true).onDrag { event -> if (!event.isLeftClick()) return@onDrag if (state.isDragging) { state.move(event.rootLocalX, event.rootLocalY) @@ -120,8 +129,7 @@ fun Modifier.dragSource(state: UiDragAndDropState?, item: () -> UiDragItem?): Mo val dragged = item() ?: return@onDrag state.begin(dragged, event.rootLocalX, event.rootLocalY) event.consume() - } - .onRelease { + }.onRelease { if (state.isDragging) state.drop() } } @@ -138,12 +146,22 @@ fun Modifier.dropTarget( accepts: (UiDragItem) -> Boolean = { true }, onDragOver: (UiDragItem, Float, Float) -> Unit = { _, _, _ -> }, onDrop: (UiDragItem, Float, Float) -> Boolean, +): Modifier = dropTarget(id, accepts, onDragOver, onDragExit = {}, onDrop = onDrop) + +@Composable +fun Modifier.dropTarget( + id: String? = null, + accepts: (UiDragItem) -> Boolean = { true }, + onDragOver: (UiDragItem, Float, Float) -> Unit = { _, _, _ -> }, + onDragExit: () -> Unit, + onDrop: (UiDragItem, Float, Float) -> Boolean, ): Modifier { val state = LocalDragAndDrop.current ?: return this val target = remember(id) { UiDropTarget(id) } SideEffect { target.accepts = accepts target.onDragOver = onDragOver + target.onDragExit = onDragExit target.onDrop = onDrop } DisposableEffect(state, target) { @@ -160,8 +178,7 @@ fun UiDragGhost(state: UiDragAndDropState, layer: Int = 500) { if (item.icon == null && item.label == null) return Box( tags = listOf("drag-ghost"), - modifier = Modifier - .position((state.pointerX + DragGhostOffset).px, (state.pointerY + DragGhostOffset).px) + modifier = Modifier.position((state.pointerX + DragGhostOffset).px, (state.pointerY + DragGhostOffset).px) .layer(layer), ) { Row(tags = listOf("drag-ghost-content"), modifier = Modifier.alignItems(vertical = UiAlign.CENTER)) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiLazyList.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiLazyList.kt index 00498b557..5aafb996d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiLazyList.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiLazyList.kt @@ -151,7 +151,12 @@ private fun LazyList( ) val body: HollowUiContent = { if (leading > 0f) Spacer(horizontal, leading) - val windowModifier = Modifier.gap(gap.px).onPlaced { rect -> + val across = when { + crossAxisScroll -> Modifier + horizontal -> Modifier.size(height = 100.percent) + else -> Modifier.size(100.percent) + } + val windowModifier = across.gap(gap.px).onPlaced { rect -> state.recordWindow(if (horizontal) rect.width else rect.height, last - first, gap) } if (horizontal) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiNode.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiNode.kt index 19e5c151c..3fffa1e8e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiNode.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiNode.kt @@ -14,6 +14,12 @@ interface UiNode { val states: MutableSet val modifiers: MutableList var resolvedModifiers: List + + /** The canvases among [resolvedModifiers]; most nodes have none, and all of them are asked twice a frame. */ + val resolvedCanvases: List + + /** Those of [resolvedModifiers] that layout reports to; the rest are not worth looking through once a frame. */ + val resolvedReports: List var resolvedSnapshot: UiComputedStyle val children: UiChildren val layoutState: UiNodeLayoutState @@ -54,6 +60,16 @@ open class BaseUiNode( final override val modifiers: MutableList = InvalidatingMutableList(modifiers) { invalidateDraw() } final override var resolvedModifiers: List = modifiers.toList() + set(value) { + if (field === value) return + field = value + resolvedCanvases = value.filterIsInstance() + resolvedReports = value.filter { it.reportsLayout() } + } + final override var resolvedCanvases: List = resolvedModifiers.filterIsInstance() + private set + final override var resolvedReports: List = resolvedModifiers.filter { it.reportsLayout() } + private set final override var resolvedSnapshot: UiComputedStyle = defaultModifierSnapshot() final override val children = UiChildren() final override var measurePolicy: UiMeasurePolicy = measurePolicy diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiRenderCommands.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiRenderCommands.kt index 3da1f3309..6c593000d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiRenderCommands.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiRenderCommands.kt @@ -1,5 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui +import net.minecraft.resources.ResourceLocation import ru.hollowhorizon.hollowengine.client.ui.layout.InlineGroupDecoration import ru.hollowhorizon.hollowengine.client.ui.layout.UiLayoutNode import ru.hollowhorizon.hollowengine.client.ui.layout.UiLayoutResult @@ -69,6 +70,11 @@ data class DrawShadowCommand( val borderWidth: Float = 0f, ) : UiRenderCommand +/** Puts the texture of the layer [node], already drawn ahead of the frame, where the layer stands in the tree. */ +data class CompositeLayerCommand( + override val node: UiNode, +) : UiRenderCommand + data class PushClipCommand( override val node: UiNode, val rect: UiRect, @@ -313,6 +319,7 @@ private fun UiRenderCommand.drawsPixels(): Boolean = when (this) { is DrawItemCommand, is DrawEntityCommand, is DrawCanvasGlCommand, + is CompositeLayerCommand, -> true is BeginLayerCommand, @@ -324,6 +331,8 @@ private fun UiRenderCommand.drawsPixels(): Boolean = when (this) { } class UiCommandRenderer { + private val svgLocations = HashMap() + /** Recursively walks the resolved tree, streaming each node's commands into [sink]. */ fun render( resolved: UiNode, @@ -373,7 +382,7 @@ class UiCommandRenderer { val isFramebuffer = layoutNode.needsFramebuffer val baseFilter = if (isFramebuffer) UiFilterChain.Empty else style.filter val localOpacity = if (isFramebuffer) 1f else style.opacity - val visibleShadows = if (style.shadows.isEmpty()) emptyList() else style.shadows.filterNot { it.inset } + val visibleShadows = style.shadows.outer() val nodeLayoutBoundsMatchVisualBounds = layoutBoundsMatchVisualBounds && style.transform == DirectLayoutTransform val canCullNode = @@ -412,7 +421,7 @@ class UiCommandRenderer { if (!cullNodeCommands && isFramebuffer) { commands += BeginLayerCommand( - node = node, rect = layoutNode.rect, radius = style.border.radius, + node = node, rect = layoutNode.rect, radius = if (style.clip) style.border.radius else 0f, clipShape = style.clipShape.takeIf { style.clip }, transform = layoutNode.worldTransform, filter = style.filter, backdropFilter = style.backdropFilter, backfaceVisibility = style.backfaceVisibility, @@ -491,7 +500,7 @@ class UiCommandRenderer { var start = 0 while (start < sorted.size) { val groupLayer = sorted[start].resolvedSnapshot.layer - var end = start + 1 + var end = if (sorted[sorted.size - 1].resolvedSnapshot.layer == groupLayer) sorted.size else start + 1 while (end < sorted.size && sorted[end].resolvedSnapshot.layer == groupLayer) end++ if (start > 0) commands += FlushBarrierCommand(node) val groupSink = if (end - start > 1 && node.childrenCanOverlap()) { @@ -555,10 +564,10 @@ class UiCommandRenderer { commands: UiRenderSink, ) { var scope: UiCommandCanvasScope? = null - val modifiers = node.resolvedModifiers - for (index in modifiers.indices) { - val modifier = modifiers[index] - if (modifier !is UiCanvasModifier || modifier.layer != layer) continue + val canvases = node.resolvedCanvases + for (index in canvases.indices) { + val modifier = canvases[index] + if (modifier.layer != layer) continue val activeScope = scope ?: canvasScope(node, layoutNode, opacity, filter, phase, commands, cornerRadius).also { scope = it } @@ -587,9 +596,6 @@ class UiCommandRenderer { filter: UiFilterChain, commands: UiRenderSink, ) { - val contentTransform = layoutNode.worldTransform.translated( - layoutNode.content.x - layoutNode.rect.x, layoutNode.content.y - layoutNode.rect.y, 0f - ) val backface = style.backfaceVisibility when (node) { @@ -601,7 +607,7 @@ class UiCommandRenderer { node.text, style.foreground, opacity, - contentTransform, + layoutNode.contentTransform(), filter, wrap = false, style.textOverflow, @@ -632,7 +638,7 @@ class UiCommandRenderer { ) } else { commands += DrawImageCommand( - node, layoutNode.content, source, opacity, style.tint, contentTransform, + node, layoutNode.content, source, opacity, style.tint, layoutNode.contentTransform(), false, style.imageFit, style.imageSlice, filter, backface, uv = style.imageUv, ) @@ -640,12 +646,12 @@ class UiCommandRenderer { } style.item?.let { commands += DrawItemCommand( - node, layoutNode.content, it, opacity, contentTransform, filter, backface, + node, layoutNode.content, it, opacity, layoutNode.contentTransform(), filter, backface, ) } style.entity?.let { commands += DrawEntityCommand( - node, layoutNode.content, it, opacity, contentTransform, false, filter, backface, + node, layoutNode.content, it, opacity, layoutNode.contentTransform(), false, filter, backface, ) } } @@ -653,9 +659,14 @@ class UiCommandRenderer { } /** The parsed (cached) vector document for an `.svg` image source, or null for raster images. */ + private fun UiLayoutNode.contentTransform(): UiMatrix4 = + worldTransform.translated(content.x - rect.x, content.y - rect.y, 0f) + private fun svgImageGeometry(source: String): UiSvgPathDocument? { if (!source.endsWith(".svg", ignoreCase = true)) return null - return runCatching { svgResourceDocument(source) }.getOrNull() + return runCatching { + svgResourceDocument(svgLocations.getOrPut(source) { parseSvgResourceLocation(source) }) + }.getOrNull() } private fun appendBackgroundCommand( @@ -697,7 +708,7 @@ class UiCommandRenderer { ) return } - if (style.background == UiPaint.None && style.border.width == UiInsets.Zero) return + if (style.background === UiPaint.None && style.border.width == UiInsets.Zero) return val paint = style.background.resolve() val border = style.border.withNormalizedRadius() if (!paint.hasVisiblePixels() && !border.hasVisiblePixels()) return @@ -869,8 +880,8 @@ internal fun UiResolvedPaint.hasVisiblePixels(): Boolean = when (this) { internal fun UiBorder.hasVisiblePixels(): Boolean = width != UiInsets.Zero && paint.resolve().hasVisiblePixels() internal fun UiPaint.resolve(): UiResolvedPaint = when (this) { - UiPaint.None -> UiResolvedPaint.None - is UiPaint.Color -> UiResolvedPaint.Color(color) + is UiPaint.Color -> resolved + is UiPaint.None -> UiResolvedPaint.None is UiPaint.LinearGradient -> UiResolvedPaint.LinearGradient(angleDegrees, stops) is UiPaint.RadialGradient -> UiResolvedPaint.RadialGradient(gradient) is UiPaint.Image -> UiResolvedPaint.Image(source) @@ -883,107 +894,55 @@ data class UiHit( val localY: Float, ) -private sealed interface HitTestTask { - data class Enter( - val node: UiNode, - val ancestorClip: UiRect?, - ) : HitTestTask - - data class Test( - val node: UiNode, - val ancestorClip: UiRect?, - ) : HitTestTask -} - class UiHitTester { fun hitTest(resolved: UiNode, layout: UiLayoutResult, x: Float, y: Float): UiHit? { - return hitNode(resolved.root, resolved, layout, x, y) + return findTopmost(resolved.root, null, layout, x, y) { current, layoutNode -> + val style = resolved[current] + if (style.inputTransparent) return@findTopmost null + if (!style.hoverable && !style.clickable && !style.focusable && !style.draggable && !style.scrollable) { + return@findTopmost null + } + if (!layoutNode.inputContains(x, y) || current.hasEffectiveState(UiState.DISABLED)) { + return@findTopmost null + } + val local = layoutNode.inputTransform.inverse()?.transform(x, y, 0f) ?: return@findTopmost null + UiHit(current, local.x, local.y) + } + } + + fun hitsVisible(resolved: UiNode, layout: UiLayoutResult, x: Float, y: Float): Boolean { + return findTopmost(resolved.root, null, layout, x, y) { current, layoutNode -> + val style = resolved[current] + true.takeIf { !style.inputTransparent && current.paintsGeometry(style) && layoutNode.inputContains(x, y) } + } ?: false } - private fun hitNode( + /** First answer of [probe] from the top of the draw order down; a clip the point is outside of hides its subtree. */ + private fun findTopmost( node: UiNode, - resolved: UiNode, + ancestorClip: UiRect?, layout: UiLayoutResult, x: Float, y: Float, - ): UiHit? { - val stack = ArrayDeque() - stack.add(HitTestTask.Enter(node, null)) - while (stack.isNotEmpty()) { - when (val task = stack.removeLast()) { - is HitTestTask.Enter -> { - val current = task.node - val layoutNode = layout.nodes[current] ?: continue - val effectiveClip = task.ancestorClip.intersect(layoutNode.clip) - stack.add(HitTestTask.Test(current, task.ancestorClip)) - for (child in layout.childrenOf(current)) { - stack.add(HitTestTask.Enter(child, effectiveClip)) - } - layout.scrollbars[current]?.forEach { scrollbar -> - if (scrollbar in layout.nodes) stack.add(HitTestTask.Enter(scrollbar, task.ancestorClip)) - } - } - - is HitTestTask.Test -> { - val current = task.node - val style = resolved[current] - if (style.inputTransparent) continue - if (!style.hoverable && !style.clickable && !style.focusable && !style.draggable && !style.scrollable) { - continue - } - if (current.hasEffectiveState(UiState.DISABLED)) continue - task.ancestorClip?.let { clip -> - if (!clip.contains(x, y)) continue - } - val layoutNode = layout[current] - if (!layoutNode.inputContains(x, y)) continue - val inverse = layoutNode.inputTransform.inverse() ?: continue - val local = inverse.transform(x, y, 0f) - return UiHit(current, local.x, local.y) - } + probe: (UiNode, UiLayoutNode) -> T?, + ): T? { + val layoutNode = layout.nodes[node] ?: return null + layout.scrollbars[node]?.let { scrollbars -> + for (index in scrollbars.indices.reversed()) { + val scrollbar = scrollbars[index] + if (scrollbar !in layout.nodes) continue + findTopmost(scrollbar, ancestorClip, layout, x, y, probe)?.let { return it } } } - return null - } - - fun hitsVisible(resolved: UiNode, layout: UiLayoutResult, x: Float, y: Float): Boolean { - return visibleNode(resolved.root, resolved, layout, x, y) - } - - private fun visibleNode(node: UiNode, resolved: UiNode, layout: UiLayoutResult, x: Float, y: Float): Boolean { - val stack = ArrayDeque() - stack.add(HitTestTask.Enter(node, null)) - while (stack.isNotEmpty()) { - when (val task = stack.removeLast()) { - is HitTestTask.Enter -> { - val current = task.node - val layoutNode = layout.nodes[current] ?: continue - val effectiveClip = task.ancestorClip.intersect(layoutNode.clip) - stack.add(HitTestTask.Test(current, task.ancestorClip)) - for (child in layout.childrenOf(current)) { - stack.add(HitTestTask.Enter(child, effectiveClip)) - } - layout.scrollbars[current]?.forEach { - if (it in layout.nodes) stack.add( - HitTestTask.Enter( - it, task.ancestorClip - ) - ) - } - } - - is HitTestTask.Test -> { - val current = task.node - val style = resolved[current] - if (style.inputTransparent || !current.paintsGeometry(style)) continue - task.ancestorClip?.let { if (!it.contains(x, y)) continue } - val layoutNode = layout[current] - if (!layoutNode.inputContains(x, y)) continue - return true - } + val clip = ancestorClip.intersect(layoutNode.clip) + if (clip == null || clip.contains(x, y)) { + val children = layout.childrenOf(node) + for (index in children.indices.reversed()) { + findTopmost(children[index], clip, layout, x, y, probe)?.let { return it } } } - return false + if (ancestorClip != null && !ancestorClip.contains(x, y)) return null + return probe(node, layoutNode) } private fun UiNode.paintsGeometry(style: UiComputedStyle): Boolean = when (this) { @@ -996,6 +955,14 @@ class UiHitTester { private val DirectLayoutTransform = UiTransform() +/** The shadows drawn outside a node; nearly always all of them, which then stay one list. */ +private fun List.outer(): List { + for (index in indices) { + if (this[index].inset) return filterNot { it.inset } + } + return this +} + private fun UiRect.hasVisibleArea(): Boolean { return width > 0f && height > 0f } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiSpring.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiSpring.kt index eb16f2a21..b57287c12 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiSpring.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiSpring.kt @@ -66,6 +66,8 @@ internal class SpringZoom( } fun advance(frameNanos: Long) = spring.advance(frameNanos) + + fun snapTo(next: Float) = spring.snapTo(next.coerceIn(min, max)) } /** How much one wheel notch scales a model preview. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiValues.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiValues.kt index b710b14e1..140890857 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiValues.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiValues.kt @@ -210,7 +210,20 @@ data class UiPopupAlignment( val popupVertical: UiAlign = UiAlign.START, val offsetX: Float = 0f, val offsetY: Float = 0f, + val flipsHorizontally: Boolean = false, ) { + fun mirrored(): UiPopupAlignment = copy( + anchorHorizontal = anchorHorizontal.mirrored(), + popupHorizontal = popupHorizontal.mirrored(), + offsetX = -offsetX, + ) + + private fun UiAlign.mirrored() = when (this) { + UiAlign.START -> UiAlign.END + UiAlign.END -> UiAlign.START + else -> this + } + companion object { val BelowStart = UiPopupAlignment() val Cursor = UiPopupAlignment(anchorVertical = UiAlign.START, offsetX = 8f, offsetY = 8f) @@ -533,6 +546,22 @@ class UiMatrix4(private val values: FloatArray) { return UiMatrix4(result) } + /** Whether both matrices hold the same values. */ + fun sameAs(other: UiMatrix4): Boolean = this === other || values.contentEquals(other.values) + + /** [translation] by these amounts applied after this matrix, in one array. */ + fun shifted(x: Float, y: Float, z: Float = 0f): UiMatrix4 { + if (x == 0f && y == 0f && z == 0f) return this + val result = values.copyOf() + for (column in 0 until 4) { + val w = values[12 + column] + result[column] += x * w + result[4 + column] += y * w + result[8 + column] += z * w + } + return UiMatrix4(result) + } + fun translated(x: Float, y: Float, z: Float = 0f): UiMatrix4 { if (x == 0f && y == 0f && z == 0f) return this return UiMatrix4(values.copyOf()).also { it.appendTranslation(x, y, z) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityAttributesEditor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityAttributesEditor.kt index 3b65b227a..5c89279dd 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityAttributesEditor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityAttributesEditor.kt @@ -7,6 +7,7 @@ import ru.hollowhorizon.hollowengine.client.ui.inspector.Hint import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorIconButton import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.ui.widgets.NumberField +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeInsight import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCompletionContributor import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover @@ -49,7 +50,7 @@ internal fun EntityAttributesEditor(scope: ComponentEditorScope) { value = query, placeholder = EntityEditorLang.search, id = "ee-attribute-search", - completionContributor = completions, + insight = UiCodeInsight(completions = completions), fontSize = 9f, onChange = { query = it }, tags = listOf("insp-input", "ee-attribute-search"), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphCanvas.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphCanvas.kt index 5f6be977c..6409e31ea 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphCanvas.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphCanvas.kt @@ -5,7 +5,6 @@ import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect -import ru.hollowhorizon.hollowengine.client.ui.shape.GenericShape import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover @@ -39,7 +38,7 @@ data class GraphPointer( } /** A link of the graph as the canvas draws it under the nodes. [key] is what a click on it reports. */ -class GraphEdge( +data class GraphEdge( val key: Any, val curve: GraphCurve, val color: UiColor = GraphColors.Edge, @@ -92,6 +91,7 @@ fun GraphCanvas( onRelease: () -> Unit = {}, onSelectArea: ((area: GraphRect, modifiers: Int) -> Unit)? = null, onKey: (UiKeyInput) -> Boolean = { false }, + onPointer: (GraphPointer?) -> Unit = {}, overlay: @Composable () -> Unit = {}, nodes: @Composable () -> Unit, ) { @@ -130,28 +130,15 @@ fun GraphCanvas( .clip(true) .onPlaced { canvas = it } .input(hoverable = true, clickable = true, draggable = true) - .drawBehind(GraphDrawKey(view.zoom, view.panX, view.panY, edges, link, hovered, frames)) { - drawGrid(view) - frames.forEach { drawFrame(it, view) } - edges.sortedBy { it.selected }.forEach { edge -> - val color = when { - edge.selected -> GraphColors.EdgeSelected - edge.key == hovered -> GraphColors.EdgeHover - else -> edge.color - } - drawCurve(edge.curve, view.zoom, color, edge.arrow) - } - link?.let { pending -> - val curve = pending.curve - if (curve != null) drawCurve(curve, view.zoom, pending.color, arrow = false) - else drawSegment(pending.fromX, pending.fromY, pending.toX, pending.toY, pending.color, 1.5f) - } - } .onHover { event -> val edge = edgeAt(event.localX, event.localY) if (edge != hovered) hovered = edge + onPointer(pointer(event)) + } + .onExit { + hovered = null + onPointer(null) } - .onExit { hovered = null } .focus() .onKeyInput { input -> when { @@ -209,6 +196,32 @@ fun GraphCanvas( event.consume() }, ) { + // A layer of its own: what a node draws behind itself is not bounded by its clip, but its children are. + Box( + modifier = Modifier + .position(0.px, 0.px) + .size(100.percent, 100.percent) + .inputTransparent() + .drawBehind(GraphDrawKey(view.zoom, view.panX, view.panY, edges, link, hovered, frames)) { + drawGrid(view) + frames.forEach { drawFrame(it, view) } + edges.sortedBy { it.selected }.forEach { edge -> + val color = when { + edge.selected -> GraphColors.EdgeSelected + edge.key == hovered -> GraphColors.EdgeHover + else -> edge.color + } + drawCurve(edge.curve, view.zoom, color, edge.arrow) + } + link?.let { pending -> + val curve = pending.curve ?: GraphCurve( + floatArrayOf(pending.fromX, pending.fromY, pending.toX, pending.toY), + ) + drawCurve(curve, view.zoom, pending.color, arrow = false) + } + }, + ) + Box( mode = UiBoxMode.STACK, modifier = Modifier @@ -297,48 +310,34 @@ private fun UiCanvasDrawScope.drawGrid(view: GraphViewState) { } } -/** Draws a curve segment by segment, each clipped to the canvas, and an arrowhead at its end. */ +/** Draws a curve as one stroke, with an arrowhead at its end when asked to. */ private fun UiCanvasDrawScope.drawCurve(curve: GraphCurve, zoom: Float, color: UiColor, arrow: Boolean) { - val points = curve.points - val width = (1.5f * zoom).coerceIn(1f, 3f) - for (index in 0 until curve.pointCount - 1) { - drawSegment(points[index * 2], points[index * 2 + 1], points[index * 2 + 2], points[index * 2 + 3], color, width) - } - if (!arrow || curve.pointCount < 2) return + if (curve.pointCount < 2) return + val paint = UiPaint.Color(color) + val stroke = UiDrawStyle.Stroke((1.5f * zoom).coerceIn(1f, 3f)) + drawShape(curve.shape, curve.bounds, paint, stroke) + if (!arrow) return + val points = curve.points val head = ARROW_SIZE * zoom val last = points.size - 2 val angle = atan2(points[last + 1] - points[last - 1], points[last] - points[last - 2]) val tipX = points[last] val tipY = points[last + 1] - drawSegment(tipX, tipY, tipX - head * cos(angle - ARROW_SPREAD), tipY - head * sin(angle - ARROW_SPREAD), color, width) - drawSegment(tipX, tipY, tipX - head * cos(angle + ARROW_SPREAD), tipY - head * sin(angle + ARROW_SPREAD), color, width) -} - -/** Lines are clipped here because a stroke drawn behind a node is not bounded by the node's clip. */ -private fun UiCanvasDrawScope.drawSegment(x1: Float, y1: Float, x2: Float, y2: Float, color: UiColor, width: Float) { - val clipped = clipSegment(x1, y1, x2, y2, size.width, size.height) ?: return - val shape = GenericShape { - moveTo(clipped[0], clipped[1]) - lineTo(clipped[2], clipped[3]) - } - drawShape(shape, bounds, UiPaint.Color(color), UiDrawStyle.Stroke(width)) + val barbs = floatArrayOf( + tipX - head * cos(angle - ARROW_SPREAD), tipY - head * sin(angle - ARROW_SPREAD), + tipX, tipY, + tipX - head * cos(angle + ARROW_SPREAD), tipY - head * sin(angle + ARROW_SPREAD), + ) + GraphCurve(barbs).let { drawShape(it.shape, it.bounds, paint, stroke) } } -/** - * A frame fills its part of the canvas and outlines it, cut to the canvas by hand, since what is drawn - * behind a node is not bounded by the node's clip. - */ +/** A frame fills its part of the canvas and outlines it. */ private fun UiCanvasDrawScope.drawFrame(frame: GraphFrame, view: GraphViewState) { val onCanvas = frame.rect.toCanvas(view) - val left = onCanvas.x.coerceAtLeast(0f) - val top = onCanvas.y.coerceAtLeast(0f) - val right = (onCanvas.x + onCanvas.width).coerceAtMost(size.width) - val bottom = (onCanvas.y + onCanvas.height).coerceAtMost(size.height) - if (right <= left || bottom <= top) return val radius = FRAME_RADIUS * view.zoom drawRect( - UiRect(left, top, right - left, bottom - top), + UiRect(onCanvas.x, onCanvas.y, onCanvas.width, onCanvas.height), UiPaint.Color(frame.color.copy(alpha = frame.color.alpha * FRAME_FILL)), radius = radius, border = UiBorder(UiInsets.all(1.px), frame.color.copy(alpha = frame.color.alpha * FRAME_OUTLINE), radius), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorController.kt new file mode 100644 index 000000000..c274d1787 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorController.kt @@ -0,0 +1,437 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.history.UndoKeys +import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput +import kotlin.math.hypot + +/** + * What every node graph does under the pointer and the keys: picking and dragging nodes, drawing links, dropping a node + * into a link, groups, reroutes and menus. [GraphEditorModel] answers for the graph, which is replaced as it recomposes. + */ +class GraphEditorController(val canvasId: String, val state: GraphEditorState) { + private var current by mutableStateOf(null) + + /** The graph as it is now. It is state, since the canvas recomposes apart from what draws the nodes. */ + var model: GraphEditorModel + get() = checkNotNull(current) + set(value) { + current = value + } + + /** Where the canvas is on the screen. */ + var canvas by mutableStateOf(UiRect.Zero) + + /** The node with a field being typed in. */ + var editing by mutableStateOf(null) + + /** + * The nodes worth building: those in sight, and those an action in progress would be lost along with. A node out of + * sight costs nothing then, neither to compose and lay out nor to draw, and the links and the minimap do not need it. + */ + val shown: Set by derivedStateOf { + val size = canvas + val selected = selection + model.scene.boxes.filter { (node, rect) -> + size.width <= 0f || node in selected || node == link?.node || node == editing || + view.sees(rect, size.width, size.height, SIGHT_MARGIN) + }.keys + } + + var pointer: GraphPointer? = null + + var search by mutableStateOf(null) + private set + + val found: List by derivedStateOf { + val words = search?.query?.trim().orEmpty() + if (words.isEmpty()) return@derivedStateOf emptyList() + val boxes = model.scene.boxes + boxes.keys.filter { model.searchText(it).contains(words, ignoreCase = true) } + .sortedWith(compareBy({ boxes.getValue(it).y }, { boxes.getValue(it).x })) + } + + var link by mutableStateOf(null) + private set + var menu by mutableStateOf(null) + var insertInto by mutableStateOf(null) + private set + + /** Whether a gesture is open in [model]. There is one at a time, whatever began it. */ + private var gesture = false + + private val dragOrigins = HashMap>() + + /** The node whose corner lands on the grid while a drag snaps; the rest keep their distance to it. */ + private var dragAnchor: String? = null + + /** The links as they lie on the canvas now, by key. */ + var curves: List> = emptyList() + + val view: GraphViewState get() = state.view + val selection: GraphSelection get() = state.selection + private val scene: GraphEditorScene get() = model.scene + + fun select(next: GraphSelection?) { + if (next != null) state.selection = next + } + + private fun beginGesture() { + endGesture() + model.beginGesture() + gesture = true + } + + private fun endGesture() { + if (!gesture) return + gesture = false + model.endGesture() + } + + /** Takes back the gesture under way, the link it drew and the nodes it dragged; false when there is none. */ + fun cancelGesture(): Boolean { + if (!gesture) return false + gesture = false + link = null + insertInto = null + dragOrigins.clear() + model.cancelGesture() + return true + } + + private fun pinAt(x: Float, y: Float, outputs: Boolean): GraphPin? = + scene.pins.filter { it.output == outputs }.map { it to hypot(view.toCanvasX(it.x) - x, view.toCanvasY(it.y) - y) } + .filter { it.second <= PIN_REACH * view.zoom.coerceAtLeast(1f) }.minByOrNull { it.second }?.first + + private fun nodeAt(x: Float, y: Float): String? = + scene.boxes.entries.lastOrNull { it.value.toCanvas(view).contains(x, y) }?.key + + private fun cardAt(x: Float, y: Float): GraphCardView? = + scene.cards.lastOrNull { it.rect.toCanvas(view).contains(x, y) } + + /** Ends a link: on a pin it links there, on a node to its first pin that takes it, on nothing it offers nodes. */ + fun finishLink() { + val pending = link ?: return + link = null + val pin = pinAt(pending.x, pending.y, outputs = !pending.fromOutput) + val node = nodeAt(pending.x, pending.y)?.takeIf { it != pending.node } + val own = scene.pins.firstOrNull { it.node == pending.node && it.name == pending.pin && it.output == pending.fromOutput } + when { + pin != null && own != null -> model.connect(own, pin) + node != null -> model.connectToNode(pending, node) + cardAt(pending.x, pending.y) != null || pending.detached -> Unit + else -> { + val items = model.offerNodes(pending, view.toGraphX(pending.x), view.toGraphY(pending.y)) + menu = GraphMenu(pending.screenX, pending.screenY, items, search = true) + } + } + endGesture() + } + + /** Ends a drag of nodes: one dropped on a link goes into it, and the whole drag is one step back. */ + fun finishDrag() { + if (dragOrigins.isEmpty()) return + insertInto?.let { link -> dragOrigins.keys.singleOrNull()?.let { model.insertIntoLink(link, it) } } + insertInto = null + joinFrame() + dragOrigins.clear() + endGesture() + } + + /** + * Nodes that were in no group and have been dragged into the frame of one join it, the way they would be put in a + * box. A frame fits itself around its nodes, so a node leaves it by the node's menu instead. + */ + private fun joinFrame() { + val loose = dragOrigins.keys.filter { id -> + !model.isGrouped(id) && dragOrigins.getValue(id) != model.positionOf(id) + } + if (loose.isEmpty()) return + val target = scene.frames.firstOrNull { frame -> + loose.all { id -> scene.boxes[id]?.let { frame.rect.contains(it.centerX, it.centerY) } == true } + } ?: return + model.setMembership(loose.toSet(), target.id) + } + + private fun beginDrag(anchor: String?) { + dragOrigins.clear() + selection.nodes.forEach { id -> model.positionOf(id)?.let { dragOrigins[id] = it } } + dragAnchor = anchor + if (dragOrigins.isEmpty()) endGesture() else beginGesture() + } + + /** A click with Shift or Control adds to the selection and takes away what was in it. */ + fun pressNode(node: String, gesture: GraphNodeGesture) { + menu = null + when { + gesture.shift || gesture.control -> { + val nodes = if (node in selection) selection.nodes - node else selection.nodes + node + select(GraphSelection(nodes)) + } + + node !in selection || selection.group != null -> select(GraphSelection.of(node)) + } + beginDrag(node) + } + + fun pressGroup(group: String, gesture: GraphNodeGesture) { + menu = null + val members = model.groupMembers(group) + when { + gesture.shift || gesture.control -> { + val nodes = if (members.all { it in selection }) selection.nodes - members else selection.nodes + members + select(GraphSelection(nodes)) + } + + selection.group != group -> select(GraphSelection(members, group = group)) + } + beginDrag(members.firstOrNull()) + } + + fun dragNode(gesture: GraphNodeGesture) { + if (link != null || dragOrigins.isEmpty()) return + val anchor = dragAnchor?.let(dragOrigins::get) ?: dragOrigins.values.first() + val dx = GraphPreferences.place(anchor.first + gesture.graphDeltaX) - anchor.first + val dy = GraphPreferences.place(anchor.second + gesture.graphDeltaY) - anchor.second + model.moveNodes(dragOrigins.mapValues { (_, at) -> at.first + dx to at.second + dy }) + val lone = dragOrigins.keys.singleOrNull()?.takeIf { !model.isLinked(it) } + insertInto = lone?.let { GraphCurves.nearest(curves, gesture.canvasX, gesture.canvasY, INSERT_REACH) } + } + + fun releaseNode() = finishDrag() + + fun pressPin(pin: GraphPin, screenX: Float, screenY: Float) { + menu = null + beginGesture() + val x = view.toCanvasX(pin.x) + val y = view.toCanvasY(pin.y) + val source = if (pin.output) null else model.detach(pin) + link = when { + pin.output -> GraphPendingLink(pin.node, pin.name, true, x, y, screenX, screenY) + source != null -> GraphPendingLink(source.node, source.name, true, x, y, screenX, screenY, detached = true) + else -> GraphPendingLink(pin.node, pin.name, false, x, y, screenX, screenY) + } + } + + fun dragPin(canvasX: Float, canvasY: Float, screenX: Float, screenY: Float) { + link = link?.copy(x = canvasX, y = canvasY, screenX = screenX, screenY = screenY) + } + + fun releasePin() = finishLink() + + /** A group taken by its title bar leaves the nodes; the nodes themselves go, those that cannot excepted. */ + fun deleteSelection() { + val link = selection.link + val group = selection.group + when { + link != null -> model.removeLink(link) + group != null -> model.removeGroup(group) + else -> { + val removable = selection.nodes.filter(model::isRemovable).toSet() + if (removable.isEmpty()) return + model.removeNodes(removable) + } + } + select(GraphSelection.None) + } + + fun duplicateSelection() { + val copied = selection.nodes.filter(model::isRemovable).toSet() + if (copied.isNotEmpty()) select(GraphSelection(model.duplicate(copied))) + } + + private fun deleteGroupWithNodes(group: String) { + val removable = model.groupMembers(group).filter(model::isRemovable).toSet() + model.beginGesture() + model.removeGroup(group) + model.removeNodes(removable) + model.endGesture() + select(GraphSelection.None) + } + + /** Selects what [area] of the graph touches, on top of the selection with Shift or Control. */ + fun selectArea(area: GraphRect, modifiers: Int) { + val covered = scene.boxes.filterValues { it.intersects(area) }.keys + + scene.cards.filter { it.rect.intersects(area) }.flatMap { model.groupMembers(it.id) } + val additive = modifiers and (GLFW.GLFW_MOD_SHIFT or GLFW.GLFW_MOD_CONTROL) != 0 + select(GraphSelection(if (additive) selection.nodes + covered else covered)) + } + + fun handleKey(input: UiKeyInput): Boolean { + when { + menu != null -> if (input.key == GLFW.GLFW_KEY_ESCAPE) menu = null else return false + HollowKeys.ADD_NODE.matches(input) -> addMenu() + HollowKeys.FIND.matches(input) -> openSearch() + HollowKeys.NEXT_FOUND.matches(input) && search != null -> stepSearch(1) + HollowKeys.PREVIOUS_FOUND.matches(input) && search != null -> stepSearch(-1) + HollowKeys.DELETE.matches(input) -> deleteSelection() + HollowKeys.DUPLICATE.matches(input) -> duplicateSelection() + HollowKeys.SELECT_ALL.matches(input) -> + select(GraphSelection(scene.boxes.keys + scene.cards.flatMap { model.groupMembers(it.id) })) + + HollowKeys.GROUP.matches(input) -> select(model.groupNodes(selection.nodes)) + HollowKeys.UNGROUP.matches(input) -> select(model.ungroup(selection)) + model.history?.let { UndoKeys.handle(it, input.key, input.modifiers) } == true -> Unit + input.key == GLFW.GLFW_KEY_ESCAPE -> if (!cancelGesture()) select(GraphSelection.None) + + else -> return false + } + return true + } + + fun nodeMenu(node: String, screenX: Float, screenY: Float) { + if (node !in selection) select(GraphSelection.of(node)) + val chosen = selection + val own = model.nodeItems(node) + val edits = if (!model.isRemovable(node)) emptyList() else listOf( + UiDropdownItem(graphEditorText("duplicate"), shortcut = HollowKeys.DUPLICATE.shortcut, onClick = ::duplicateSelection), + UiDropdownItem(graphEditorText("delete_node"), shortcut = HollowKeys.DELETE.shortcut, onClick = ::deleteSelection), + ) + menu = GraphMenu(screenX, screenY, joinItemGroups(own, edits, organizeItems(chosen))) + } + + fun groupMenu(group: String, screenX: Float, screenY: Float) { + val members = model.groupMembers(group) + if (selection.group != group) select(GraphSelection(members, group = group)) + val collapsed = model.isCollapsed(group) + menu = GraphMenu( + screenX, screenY, + listOfNotNull( + UiDropdownItem(graphEditorText(if (collapsed) "expand_group" else "collapse_group")) { model.toggleGroup(group) }, + UiDropdownItem( + graphEditorText("ungroup"), icon = UngroupIcon, shortcut = HollowKeys.UNGROUP.shortcut, + ) { select(model.ungroup(GraphSelection(members, group = group))) }, + if (collapsed) null else arrangeItem(members), + UiDropdownItem(graphEditorText("delete_group_nodes"), separatorBefore = true) { deleteGroupWithNodes(group) }, + ), + ) + } + + fun linkMenu(link: Any, pointer: GraphPointer) { + select(GraphSelection.ofLink(link)) + menu = GraphMenu( + pointer.screenX, pointer.screenY, + listOf( + graphRerouteItem { select(model.addReroute(link, pointer.graphX, pointer.graphY)) }, + UiDropdownItem(graphEditorText("delete_link"), shortcut = HollowKeys.DELETE.shortcut) { + model.removeLink(link) + select(GraphSelection.None) + }, + ), + ) + } + + fun backgroundMenu(pointer: GraphPointer) { + menu = GraphMenu( + pointer.screenX, pointer.screenY, + model.addItems(pointer.graphX, pointer.graphY) + graphViewItems(), + search = true, + ) + } + + /** The menu of what can be added, under the pointer, or in the middle of the canvas while the pointer is away. */ + fun addMenu() { + val at = pointer ?: GraphPointer( + canvas.x + canvas.width / 2f, canvas.y + canvas.height / 2f, + canvas.width / 2f, canvas.height / 2f, + view.toGraphX(canvas.width / 2f), view.toGraphY(canvas.height / 2f), + ) + menu = GraphMenu(at.screenX, at.screenY, model.addItems(at.graphX, at.graphY), search = true) + } + + fun openSearch() { + val open = search ?: GraphSearch().also { search = it } + open.focusRequests++ + } + + fun closeSearch() { + search = null + } + + /** Looks for [query] anew and brings the first node it finds into view. */ + fun searchFor(query: String) { + val open = search ?: return + open.query = query + open.index = 0 + found.firstOrNull()?.let(::reveal) + } + + /** Brings the next node found into view, or the one before it when [step] is negative. */ + fun stepSearch(step: Int) { + val open = search ?: return + if (found.isEmpty()) return + open.index = (open.index + step).mod(found.size) + reveal(found[open.index]) + } + + /** Selects [node] and brings it to the middle of the canvas. */ + fun reveal(node: String) { + val rect = scene.boxes[node] ?: return + select(GraphSelection.of(node)) + view.centerOn(rect.centerX, rect.centerY, canvas.width, canvas.height) + } + + /** Grouping the selection, taking it out of its groups, and lining it up. */ + private fun organizeItems(chosen: GraphSelection): List { + val nodes = chosen.nodes + val grouped = nodes.any(model::isGrouped) + return listOfNotNull( + UiDropdownItem( + graphEditorText("group_nodes"), icon = GroupIcon, shortcut = HollowKeys.GROUP.shortcut, + ) { select(model.groupNodes(nodes)) }.takeIf { nodes.isNotEmpty() }, + UiDropdownItem(graphEditorText("leave_group")) { model.setMembership(nodes, null) }.takeIf { grouped }, + UiDropdownItem( + graphEditorText("ungroup"), icon = UngroupIcon, shortcut = HollowKeys.UNGROUP.shortcut, + ) { select(model.ungroup(chosen)) }.takeIf { grouped }, + arrangeItem(nodes), + ) + } + + private fun arrangeItem(ids: Collection): UiDropdownItem? { + val boxes: Map = ids.mapNotNull { id -> scene.boxes[id]?.let { id to it } }.toMap() + fun move(to: Map>) { + if (to.isEmpty()) return + model.beginGesture() + model.moveNodes(to) + model.endGesture() + } + return graphArrangeItem( + boxes.size, + onAlign = { alignment -> move(GraphArrange.align(boxes, alignment)) }, + onDistribute = { horizontal -> move(GraphArrange.distribute(boxes, horizontal)) }, + ) + } + + private fun joinItemGroups(vararg groups: List): List = buildList { + groups.filter { it.isNotEmpty() }.forEach { group -> + group.forEachIndexed { index, item -> + add(if (index == 0 && isNotEmpty()) item.copy(separatorBefore = true) else item) + } + } + } + + private companion object { + /** How far from a pin, in canvas pixels at zoom 1, a link can be dropped and still land on it. */ + const val PIN_REACH = 12f + + /** How close to a link, in canvas pixels, a lone node has to be dragged to go into it. */ + const val INSERT_REACH = 10f + + /** How far past the canvas, in canvas pixels, nodes stay built: shadows and pins reach out of a node. */ + const val SIGHT_MARGIN = 96f + + const val GroupIcon = "hollowengine:textures/gui/icons/actions/group.svg" + const val UngroupIcon = "hollowengine:textures/gui/icons/actions/ungroup.svg" + } +} + +/** The item that puts a reroute where a link was dropped, or on the link that was clicked. */ +fun graphRerouteItem(onPick: () -> Unit) = + UiDropdownItem(graphEditorText("reroute"), icon = "hollowengine:textures/gui/icons/actions/reroute.svg", onClick = onPick) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorModel.kt new file mode 100644 index 000000000..a9ca9e872 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorModel.kt @@ -0,0 +1,217 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.client.history.UndoHistory +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem + +/** One pin of a placed node, where it sits in graph units. */ +data class GraphPin(val node: String, val name: String, val output: Boolean, val x: Float, val y: Float) + +/** + * What is selected: some nodes, or one link by the key its graph gave it. A group taken by its title bar selects its + * nodes and names itself in [group], which is what an inspector shows. + */ +data class GraphSelection(val nodes: Set = emptySet(), val link: Any? = null, val group: String? = null) { + val single: String? get() = nodes.singleOrNull() + + operator fun contains(node: String): Boolean = node in nodes + + companion object { + val None = GraphSelection() + + fun of(node: String) = GraphSelection(setOf(node)) + fun ofLink(link: Any) = GraphSelection(link = link) + } +} + +/** What an editor of one graph keeps while its file stays open. */ +class GraphEditorState { + val view = GraphViewState() + var selection by mutableStateOf(GraphSelection.None) +} + +/** + * A link being drawn from a pin: where the pointer is on the canvas, and on the screen for the menu of a drop on nothing. + * + * @param detached whether it was taken off the input it ended at, rather than pulled out of a pin anew. + */ +data class GraphPendingLink( + val node: String, + val pin: String, + val fromOutput: Boolean, + val x: Float, + val y: Float, + val screenX: Float, + val screenY: Float, + val detached: Boolean = false, +) + +/** + * A context menu an editor has open, where it was opened on the screen. + * + * @param search whether it has a field that finds its items through every submenu. + */ +data class GraphMenu(val x: Float, val y: Float, val items: List, val search: Boolean = false) + +/** A link as the canvas draws it. [key] tells it apart in a [GraphSelection] and in [GraphEditorModel]. */ +class GraphLinkView(val key: Any, val from: GraphPin, val to: GraphPin, val color: UiColor) + +/** An open group: the frame around its nodes and what its title bar shows. */ +class GraphFrameView(val id: String, val title: String, val color: UiColor, val rect: GraphRect) + +/** A collapsed group, drawn as one node with a pin for each link that crosses it. */ +class GraphCardView( + val id: String, + val title: String, + val color: UiColor, + val rect: GraphRect, + val inputs: List, + val outputs: List, +) { + val pins: List get() = inputs + outputs + + companion object { + /** + * A card at the corner of [around], with a pin for each of [entering] and [leaving], the node and pin of each + * link that crosses the group. + */ + fun of( + id: String, + title: String, + color: UiColor, + around: GraphRect, + entering: List>, + leaving: List>, + ): GraphCardView { + val rows = entering.size + leaving.size + val body = if (rows == 0) 0f else GraphNodeMetrics.PADDING * 2 + rows * GraphNodeMetrics.ROW + val rect = GraphRect(around.x, around.y, GraphNodeMetrics.WIDTH, GraphNodeMetrics.HEADER + body) + + fun rowY(index: Int) = + rect.y + GraphNodeMetrics.HEADER + GraphNodeMetrics.PADDING + GraphNodeMetrics.ROW * (index + 0.5f) + return GraphCardView( + id, title, color, rect, + inputs = entering.mapIndexed { index, (node, pin) -> + GraphPin( + node, + pin, + false, + rect.x, + rowY(leaving.size + index) + ) + }, + outputs = leaving.mapIndexed { index, (node, pin) -> + GraphPin( + node, + pin, + true, + rect.x + rect.width, + rowY(index) + ) + }, + ) + } + } +} + +/** What the canvas shows of a graph, with its groups taken into account. */ +class GraphEditorScene( + val boxes: Map, + val titles: Map, + val pins: List, + val links: List, + val frames: List = emptyList(), + val cards: List = emptyList(), + val reroutes: Set = emptySet(), +) { + /** The way links run through each of the [reroutes]. */ + val passing: Map by lazy { + reroutes.associateWith { id -> + val at = links.firstOrNull { it.to.node == id }?.to ?: links.firstOrNull { it.from.node == id }?.from + val from = links.filter { it.to.node == id }.map { it.from.x to it.from.y } + val to = links.filter { it.from.node == id }.map { it.to.x to it.to.y } + GraphCurves.passing(at?.x ?: 0f, at?.y ?: 0f, from, to) + } + } +} + +/** + * What a graph is to [GraphEditorController]: a scene, and the edits gestures on it end in. The controller owns what + * every graph does alike, a kind of graph answers only what depends on its nodes. + */ +interface GraphEditorModel { + val scene: GraphEditorScene + val history: UndoHistory? + + fun beginGesture() + fun endGesture() + + /** Ends the gesture with the graph back as it was when it began; nothing of it stays in the history. */ + fun cancelGesture() + + /** Where the corner of [node] is, also while a collapsed group hides it. */ + fun positionOf(node: String): Pair? + + /** Puts each of the nodes where [positions] says. */ + fun moveNodes(positions: Map>) + + /** The color of what [pin] carries, for the link drawn from it. */ + fun pinColor(pin: GraphPin): UiColor + + /** What [pin] carries, in words, shown when the pointer is on it. */ + fun pinTooltip(pin: GraphPin): String? = null + + /** Links [from] to [to], an output and an input in either order. */ + fun connect(from: GraphPin, to: GraphPin) + + /** Links what [pending] carries to the first pin of [node] that takes it. */ + fun connectToNode(pending: GraphPendingLink, node: String) + + /** Takes the link off [input] to be dropped elsewhere and gives the pin it came from; null when it stays. */ + fun detach(input: GraphPin): GraphPin? + + /** The items of the menu of a link dropped on nothing, at ([x], [y]) of the graph. */ + fun offerNodes(pending: GraphPendingLink, x: Float, y: Float): List + + /** Whether [node] has any links, so that dropping it on a link cannot go into it. */ + fun isLinked(node: String): Boolean + + /** Puts the lone [node] into [link]. */ + fun insertIntoLink(link: Any, node: String) + + fun removeLink(link: Any) + + /** A reroute put into [link], centered on ([x], [y]); it is selected. */ + fun addReroute(link: Any, x: Float, y: Float): GraphSelection + + fun removeNodes(nodes: Set) + + /** Copies [nodes] next to them and gives the copies. */ + fun duplicate(nodes: Set): Set + + fun groupMembers(group: String): Set + fun isGrouped(node: String): Boolean + fun groupNodes(nodes: Set): GraphSelection? + fun ungroup(selection: GraphSelection): GraphSelection? + + /** Puts [nodes] in [group], or out of every group when it is null. */ + fun setMembership(nodes: Set, group: String?) + fun removeGroup(group: String) + fun toggleGroup(group: String) + fun isCollapsed(group: String): Boolean + + /** What a search of the graph finds [node] by: its title, and what its fields hold. */ + fun searchText(node: String): String = scene.titles[node].orEmpty() + + /** What the menu of [node] offers ahead of copying, removing and grouping. */ + fun nodeItems(node: String): List = emptyList() + + /** Whether [node] can be copied and removed. */ + fun isRemovable(node: String): Boolean = true + + /** The items of the menu of an empty spot, at ([x], [y]) of the graph. */ + fun addItems(x: Float, y: Float): List +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorViews.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorViews.kt new file mode 100644 index 000000000..10ca035d6 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorViews.kt @@ -0,0 +1,436 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNodeMetrics.FIELD_ROW +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import ru.hollowhorizon.hollowengine.client.utils.lang + +const val GraphNodesStylesheet = "hollowengine:ui/styles/graph-nodes.hss" + +val GraphLinkColor = UiColor.fromArgb(0xFFA5ABC0.toInt()) +val GraphSelectedColor = UiColor(0.43f, 0.61f, 0.86f) + +fun graphEditorText(name: String): String = "hollowengine.gui.graph.editor.$name".lang + +/** The sizes of the nodes of a graph, in graph units. */ +object GraphNodeMetrics { + const val WIDTH = 216f + + /** The title bar of a collapsed node, which is all of it; an open node has a line under it too. */ + const val COLLAPSED = 32f + const val HEADER = COLLAPSED + 1f + + /** A row that is a name alone; one with a field to edit is [FIELD_ROW] tall. */ + const val ROW = 21f + const val FIELD_ROW = 27f + + /** How far the rows sit from the edges of the node, sideways and above the first and under the last. */ + const val INSET = 12f + const val PADDING = 10f + const val PIN = 9f + + /** How wide a reroute dot is. */ + const val REROUTE = 12f +} + +/** + * The canvas of a node graph with everything [GraphEditorController] does on it. [content] draws the scene, usually + * through [GraphFrameHeaders], [GraphNodeFrame] and [GraphGroupCard]. + */ +@Composable +fun GraphEditorCanvas( + controller: GraphEditorController, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val view = controller.view + val scene = controller.model.scene + val selection = controller.selection + DisposableEffect(controller) { onDispose { controller.cancelGesture() } } + val curves = scene.links.map { each -> + each to GraphCurves.throughPoints( + view.toCanvasX(each.from.x), view.toCanvasY(each.from.y), scene.passing[each.from.node], + view.toCanvasX(each.to.x), view.toCanvasY(each.to.y), scene.passing[each.to.node], + view.zoom, + ) + } + controller.curves = curves.map { (each, curve) -> each.key to curve } + val minimap = remember(scene, selection) { + scene.boxes.map { (id, rect) -> + GraphMiniMapItem( + rect, + if (id in selection) GraphSelectedColor else GraphLinkColor, + scene.titles[id].orEmpty() + ) + } + scene.cards.map { GraphMiniMapItem(it.rect, it.color, it.title) } + } + Box(mode = UiBoxMode.STACK, modifier = modifier.onPlaced { controller.canvas = it }) { + GraphCanvas( + id = controller.canvasId, + view = view, + modifier = Modifier.size(100.percent, 100.percent), + edges = curves.map { (each, curve) -> + GraphEdge( + key = each.key, + curve = curve, + color = each.color, + selected = selection.link == each.key || controller.insertInto == each.key, + ) + }, + link = controller.preview(), + frames = scene.frames.map { GraphFrame(it.rect, it.color) }, + minimap = minimap, + onBackgroundClick = { + controller.menu = null + controller.select(GraphSelection.None) + }, + onEdgeClick = { link -> controller.select(GraphSelection.ofLink(link)) }, + onEdgeDoubleClick = { link, pointer -> + controller.select( + controller.model.addReroute( + link, + pointer.graphX, + pointer.graphY + ) + ) + }, + onContextMenu = { pointer, edge -> + if (edge != null) controller.linkMenu(edge, pointer) else controller.backgroundMenu(pointer) + }, + onRelease = { + controller.finishLink() + controller.finishDrag() + }, + onSelectArea = controller::selectArea, + onKey = controller::handleKey, + onPointer = { controller.pointer = it }, + nodes = content, + ) + + controller.search?.let { GraphSearchBar(controller, it) } + controller.menu?.let { open -> + key(open) { + ContextMenu( + id = "${controller.canvasId}-menu", + anchorBounds = UiRect(open.x, open.y, 0f, 0f), + items = open.items, + search = graphEditorText("search_items").takeIf { open.search }, + onExpandedChange = { if (!it) controller.menu = null }, + ) + } + } + } +} + +/** What the link being drawn looks like now. */ +private fun GraphEditorController.preview(): GraphLinkPreview? { + val pending = link ?: return null + val pin = + model.scene.pins.firstOrNull { it.node == pending.node && it.name == pending.pin && it.output == pending.fromOutput } + ?: return null + val fromX = view.toCanvasX(pin.x) + val fromY = view.toCanvasY(pin.y) + val curve = if (pending.fromOutput) GraphCurves.betweenPins(fromX, fromY, pending.x, pending.y, view.zoom) + else GraphCurves.betweenPins(pending.x, pending.y, fromX, fromY, view.zoom) + return GraphLinkPreview(fromX, fromY, pending.x, pending.y, model.pinColor(pin), curve) +} + +/** The title bars of the open groups. */ +@Composable +fun GraphFrameHeaders(controller: GraphEditorController) { + val selection = controller.selection + controller.model.scene.frames.forEach { frame -> + key("frame-${frame.id}") { + GraphGroupHeader( + id = "gn-frame-${frame.id}", + canvasId = controller.canvasId, + view = controller.view, + frame = frame.rect, + title = frame.title, + color = frame.color, + selected = selection.group == frame.id, + onPress = { controller.pressGroup(frame.id, it) }, + onDrag = controller::dragNode, + onRelease = controller::releaseNode, + onContextMenu = { x, y -> controller.groupMenu(frame.id, x, y) }, + onToggle = { controller.model.toggleGroup(frame.id) }, + ) + } + } +} + +/** A node at [rect] that the pointer picks and drags and whose right click opens its menu. */ +/** Marks a field of [node]: the node stays built while the field is being typed in, wherever the view goes. */ +fun Modifier.graphField(controller: GraphEditorController, node: String) = + onFocus { controller.editing = node }.onUnfocus { if (controller.editing == node) controller.editing = null } + +@Composable +fun GraphNodeFrame( + controller: GraphEditorController, + node: String, + rect: GraphRect, + tags: List, + content: @Composable () -> Unit, +) { + GraphNode( + id = "gn-node-$node", + canvasId = controller.canvasId, + view = controller.view, + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + selected = node in controller.selection, + tags = if (node in controller.found) tags + "gn-found" else tags, + onPress = { controller.pressNode(node, it) }, + onDrag = controller::dragNode, + onRelease = controller::releaseNode, + onContextMenu = { x, y -> controller.nodeMenu(node, x, y) }, + content = { Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { content() } }, + ) +} + +/** + * A reroute: a dot in the color of what passes through it. A drag moves it; Alt and a drag pull a new link out of it, + * since a dot has no room for a pin of its own. + */ +@Composable +fun GraphRerouteView( + controller: GraphEditorController, + node: String, + rect: GraphRect, + output: GraphPin, + tags: List, +) { + val linking = remember { booleanArrayOf(false) } + GraphNode( + id = "gn-node-$node", + canvasId = controller.canvasId, + view = controller.view, + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + selected = node in controller.selection, + tags = listOf("gn-reroute") + tags, + onPress = { gesture -> + linking[0] = gesture.alt + if (gesture.alt) controller.pressPin(output, gesture.screenX, gesture.screenY) else controller.pressNode( + node, + gesture + ) + }, + onDrag = { gesture -> + if (linking[0]) controller.dragPin(gesture.canvasX, gesture.canvasY, gesture.screenX, gesture.screenY) + else controller.dragNode(gesture) + }, + onRelease = { + if (linking[0]) controller.releasePin() else controller.releaseNode() + linking[0] = false + }, + onContextMenu = { x, y -> controller.nodeMenu(node, x, y) }, + ) {} +} + +/** A pin on the edge of its node, in the color of what it carries, filled once something is linked. */ +@Composable +fun GraphPinView( + controller: GraphEditorController, + pin: GraphPin, + nodeX: Float, + nodeY: Float, + connected: Boolean, + tags: List = emptyList(), + tooltip: String? = null, +) { + val half = GraphNodeMetrics.PIN / 2f + val held = remember { booleanArrayOf(false) } + Box( + id = "gn-pin-${pin.node}-${if (pin.output) "out" else "in"}-${pin.name}", + tags = listOf("gn-pin") + tags + listOfNotNull("connected".takeIf { connected }), + modifier = Modifier.position((pin.x - nodeX - half).px, (pin.y - nodeY - half).px) + .size(GraphNodeMetrics.PIN.px, GraphNodeMetrics.PIN.px) + .let { if (tooltip != null) it.tooltipOnHover(tooltip) else it } + .input(hoverable = true, clickable = true, draggable = true).onPress { event -> + if (event.button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return@onPress + held[0] = true + controller.pressPin(pin, event.x, event.y) + event.consume() + }.onDrag { event -> + if (!held[0]) return@onDrag + event.ancestorLocalPositions[controller.canvasId]?.let { + controller.dragPin( + it.x, + it.y, + event.x, + event.y + ) + } + event.consume() + }.onRelease { event -> + if (!held[0]) return@onRelease + held[0] = false + controller.releasePin() + event.consume() + }, + ) +} + +/** + * The title bar in the color of the category: its icon, the name, and the arrow that collapses the node. + * + * @param node what the arrow's id is made from. + * @param problem what is wrong with the node, told by a mark on the bar: the bar itself has to keep taking the pointer. + * @param color what the bar is painted in, when the style of the node does not say. + * @param warning whether [problem] lets the node work all the same. + */ +@Composable +fun GraphNodeHeader( + node: String, + title: String, + icon: String?, + width: Float, + collapsed: Boolean, + problem: String? = null, + color: UiColor? = null, + warning: Boolean = false, + onToggle: () -> Unit, +) { + val bar = GraphNodeMetrics.COLLAPSED + fun Modifier.painted() = if (color != null) background(color) else this + Row( + tags = listOf("gn-node-head"), + modifier = Modifier.position(0.px, 0.px).size(width.px, bar.px).alignItems(vertical = UiAlign.CENTER).painted(), + ) { + if (problem != null) { + Image( + if (warning) WarningIcon else ErrorIcon, + tags = listOf("gn-node-problem"), + modifier = Modifier.tooltipOnHover(problem) + ) + } + if (icon != null) Image(icon, tags = listOf("gn-node-icon")) + Text(title, tags = listOf("gn-node-title"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f)) + Box( + id = "gn-collapse-$node", + tags = listOf("gn-node-toggle"), + modifier = Modifier.input(hoverable = true, clickable = true).onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onToggle() + event.consume() + }, + ) { + Image(ArrowIcon, tags = listOf("gn-node-arrow")) + } + } + if (collapsed) return + Box( + tags = listOf("gn-node-head-flat"), + modifier = Modifier.position(0.px, (bar - SquaredStrip).px).size(width.px, SquaredStrip.px).painted() + .inputTransparent(), + ) + listOf(0f, width - 1f).forEach { x -> + Box( + tags = listOf("gn-node-head-edge"), + modifier = Modifier.position(x.px, (bar - SquaredStrip).px).size(1.px, SquaredStrip.px).inputTransparent(), + ) + } + Box( + tags = listOf("gn-node-divider"), + modifier = Modifier.position(0.px, bar.px).size(width.px, 1.px).inputTransparent(), + ) +} + +/** + * A collapsed group as one node: a title bar in the group's color with an arrow that opens it again, and a row per pin + * surrounding links reach, named after the pin and the node it belongs to. + * + * @param pinName how a pin is called on the card. + * @param ownerName the title of the node a pin belongs to. + */ +@Composable +fun GraphGroupCard( + controller: GraphEditorController, + card: GraphCardView, + pinName: (GraphPin) -> String, + ownerName: (GraphPin) -> String, + pinTags: (GraphPin) -> List = { emptyList() }, +) { + val rect = card.rect + GraphNode( + id = "gn-group-${card.id}", + canvasId = controller.canvasId, + view = controller.view, + x = rect.x, + y = rect.y, + width = rect.width, + height = rect.height, + selected = controller.selection.group == card.id, + tags = listOf("gn-node", "gn-group-card"), + onPress = { controller.pressGroup(card.id, it) }, + onDrag = controller::dragNode, + onRelease = controller::releaseNode, + onContextMenu = { x, y -> controller.groupMenu(card.id, x, y) }, + ) { + Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { + Row( + tags = listOf("gn-node-head", "gn-group-head"), + modifier = Modifier.position(0.px, 0.px).size(rect.width.px, GraphNodeMetrics.COLLAPSED.px) + .background(card.color).alignItems(vertical = UiAlign.CENTER), + ) { + Image(GroupIcon, tags = listOf("gn-node-icon")) + Text(card.title, tags = listOf("gn-node-title"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f)) + GraphGroupToggle( + "gn-group-toggle-${card.id}", + collapsed = true + ) { controller.model.toggleGroup(card.id) } + } + card.pins.forEach { pin -> CardRow(pin, pinName(pin), ownerName(pin), rect.y, rect.width) } + card.pins.forEach { pin -> + GraphPinView( + controller, + pin, + rect.x, + rect.y, + connected = true, + tags = pinTags(pin), + tooltip = controller.model.pinTooltip(pin), + ) + } + } + } +} + +/** The name of a pin of a collapsed group next to the pin, with the node it belongs to on the inner side. */ +@Composable +private fun CardRow(pin: GraphPin, name: String, owner: String, y: Float, width: Float) { + val half = GraphNodeMetrics.ROW / 2f + Row( + tags = listOf("gn-node-row"), + modifier = Modifier.position(0.px, (pin.y - y - half).px).size(width.px, GraphNodeMetrics.ROW.px) + .padding(GraphNodeMetrics.INSET.px, 0.px).alignItems(vertical = UiAlign.CENTER).inputTransparent(), + ) { + if (pin.output) { + Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) + Text(owner, tags = listOf("gn-pin-hint")) + Text(name, tags = listOf("gn-pin-label", "output")) + } else { + Text(name, tags = listOf("gn-pin-label")) + Text(owner, tags = listOf("gn-pin-hint")) + } + } +} + +private const val ArrowIcon = "hollowengine:textures/gui/icons/graph/arrow.svg" +private const val GroupIcon = "hollowengine:textures/gui/icons/actions/group.svg" +private const val ErrorIcon = "hollowengine:textures/gui/icons/error.svg" +private const val WarningIcon = "hollowengine:textures/gui/icons/warn.svg" + +/** How tall the strip is that squares off the bottom corners of the title bar of an open node. */ +private const val SquaredStrip = 8f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphGeometry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphGeometry.kt index c8d86a455..c8fb1516b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphGeometry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphGeometry.kt @@ -1,7 +1,14 @@ package ru.hollowhorizon.hollowengine.client.ui.graph +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphCurves.HANDLE +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.shape.GenericShape +import ru.hollowhorizon.hollowengine.client.ui.shape.Shape +import ru.hollowhorizon.hollowengine.client.ui.shape.UiPath +import ru.hollowhorizon.hollowengine.client.ui.shape.UiShapeSize import kotlin.math.abs import kotlin.math.hypot +import kotlin.math.round /** A rectangle of the graph or of the canvas, whichever space it was made in. */ data class GraphRect(val x: Float, val y: Float, val width: Float, val height: Float) { @@ -40,6 +47,18 @@ data class GraphRect(val x: Float, val y: Float, val width: Float, val height: F class GraphCurve(val points: FloatArray) { val pointCount: Int get() = points.size / 2 + /** Where the curve lies, a little wider than its points so that a straight line still has an area. */ + val bounds: UiRect by lazy { + val xs = FloatArray(pointCount) { points[it * 2] } + val ys = FloatArray(pointCount) { points[it * 2 + 1] } + val left = xs.min() - BOUNDS_MARGIN + val top = ys.min() - BOUNDS_MARGIN + UiRect(left, top, xs.max() + BOUNDS_MARGIN - left, ys.max() + BOUNDS_MARGIN - top) + } + + /** The whole curve as one path inside [bounds]; curves of the same form share its geometry. */ + val shape: Shape by lazy { GraphCurveShape(points, bounds.x, bounds.y) } + /** A point [fraction] of the way along the curve. */ fun pointAt(fraction: Float): FloatArray { val last = pointCount - 1 @@ -47,6 +66,11 @@ class GraphCurve(val points: FloatArray) { return floatArrayOf(points[step * 2], points[step * 2 + 1]) } + /** Whether ([x], [y]) can be within [radius] of the curve at all. */ + fun passesNear(x: Float, y: Float, radius: Float): Boolean = bounds.let { + x >= it.x - radius && y >= it.y - radius && x <= it.x + it.width + radius && y <= it.y + it.height + radius + } + fun distanceTo(x: Float, y: Float): Float = (0 until pointCount - 1).minOfOrNull { index -> distanceToSegment( x, y, @@ -59,11 +83,35 @@ class GraphCurve(val points: FloatArray) { override fun hashCode() = points.contentHashCode() } +private const val BOUNDS_MARGIN = 1f +private const val SHAPE_PRECISION = 32f + +/** Points are snapped to a fraction of a pixel so that panning, which only moves a curve, finds the same geometry. */ +private class GraphCurveShape(points: FloatArray, originX: Float, originY: Float) : Shape { + private val local = FloatArray(points.size) { + round((points[it] - if (it % 2 == 0) originX else originY) * SHAPE_PRECISION) / SHAPE_PRECISION + } + private val hash = local.contentHashCode() + + override fun createPath(size: UiShapeSize): UiPath = GenericShape { + moveTo(local[0], local[1]) + for (index in 2 until local.size step 2) lineTo(local[index], local[index + 1]) + }.createPath(size) + + override fun equals(other: Any?) = other is GraphCurveShape && local.contentEquals(other.local) + override fun hashCode() = hash +} + +/** The way a link runs at a point it passes through, as a vector of length one. */ +class GraphDirection(val x: Float, val y: Float) + /** How the two kinds of graph link their nodes. */ object GraphCurves { private const val SAMPLES = 32 private const val MIN_BOW = 26f private const val MAX_BOW = 220f + private const val HANDLE = 0.35f + private const val MIN_LENGTH = 1e-3f /** * A link from a side of [from] into a side of [to]. @@ -91,10 +139,51 @@ object GraphCurves { return bezier(fromX, fromY, fromX + bow, fromY, toX - bow, toY, toX, toY) } + /** + * A link between two pins, either of which can be a point the link passes through. It keeps to [leave] and [enter] + * there, for a [HANDLE] of its length: unlike a pin's reach that cannot overshoot, so a link cannot tie a knot. + */ + fun throughPoints( + fromX: Float, fromY: Float, leave: GraphDirection?, + toX: Float, toY: Float, enter: GraphDirection?, + zoom: Float, + ): GraphCurve { + if (leave == null && enter == null) return betweenPins(fromX, fromY, toX, toY, zoom) + val reach = hypot(toX - fromX, toY - fromY) + val bow = (abs(toX - fromX) * 0.5f).coerceIn(MIN_BOW * zoom, MAX_BOW * zoom).coerceAtMost(reach * 0.5f) + val handle = reach * HANDLE + return bezier( + fromX, fromY, + fromX + (leave?.let { it.x * handle } ?: bow), fromY + (leave?.let { it.y * handle } ?: 0f), + toX - (enter?.let { it.x * handle } ?: bow), toY - (enter?.let { it.y * handle } ?: 0f), + toX, toY, + ) + } + + /** + * The way a link runs through ([x], [y]), coming from the points [from] and going to the points [to]: between the + * two ways, so the curves on both sides of the point meet without a corner. + */ + fun passing(x: Float, y: Float, from: List>, to: List>): GraphDirection { + var sumX = 0f + var sumY = 0f + fun add(dx: Float, dy: Float) { + val length = hypot(dx, dy) + if (length > 0f) { + sumX += dx / length + sumY += dy / length + } + } + from.forEach { (px, py) -> add(x - px, y - py) } + to.forEach { (px, py) -> add(px - x, py - y) } + val length = hypot(sumX, sumY) + return if (length < MIN_LENGTH) GraphDirection(1f, 0f) else GraphDirection(sumX / length, sumY / length) + } + /** The key of the curve closest to ([x], [y]) within [radius], if any is. */ fun nearest(curves: List>, x: Float, y: Float, radius: Float): K? = - curves.map { (key, curve) -> key to curve.distanceTo(x, y) } - .filter { it.second <= radius } + curves.filter { (_, curve) -> curve.passesNear(x, y, radius) } + .map { (key, curve) -> key to curve.distanceTo(x, y) }.filter { it.second <= radius } .minByOrNull { it.second }?.first private fun bezier( @@ -146,28 +235,3 @@ internal fun distanceToSegment(x: Float, y: Float, x1: Float, y1: Float, x2: Flo val t = (((x - x1) * dx + (y - y1) * dy) / lengthSquared).coerceIn(0f, 1f) return hypot(x - (x1 + t * dx), y - (y1 + t * dy)) } - -/** The part of the segment inside a [width] by [height] box at the origin, or null when none is. */ -internal fun clipSegment(x1: Float, y1: Float, x2: Float, y2: Float, width: Float, height: Float): FloatArray? { - val dx = x2 - x1 - val dy = y2 - y1 - var enter = 0f - var exit = 1f - - val checks = arrayOf(-dx to x1, dx to (width - x1), -dy to y1, dy to (height - y1)) - checks.forEach { (p, q) -> - if (p == 0f) { - if (q < 0f) return null - } else { - val r = q / p - if (p < 0f) { - if (r > exit) return null - if (r > enter) enter = r - } else { - if (r < enter) return null - if (r < exit) exit = r - } - } - } - return floatArrayOf(x1 + enter * dx, y1 + enter * dy, x1 + exit * dx, y1 + exit * dy) -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphSearch.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphSearch.kt new file mode 100644 index 000000000..fa1273843 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphSearch.kt @@ -0,0 +1,97 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover + +/** What is being looked for among the nodes, and which of the nodes found is in view. */ +class GraphSearch { + var query by mutableStateOf("") + var index by mutableStateOf(0) + + /** Grows each time the search is asked for, so that its field takes the keys again while it stays open. */ + var focusRequests by mutableStateOf(0) +} + +/** The bar over the corner of the canvas that finds nodes by what they are called and what they hold. */ +@Composable +internal fun GraphSearchBar(controller: GraphEditorController, search: GraphSearch) { + val found = controller.found + val focus = LocalUiFocusRequester.current + val field = "${controller.canvasId}-search" + + fun close() { + controller.closeSearch() + focus.request(controller.canvasId) + } + + Column( + modifier = Modifier.size(100.percent, 100.percent).padding(8.px).inputTransparent().style(GraphStylesheet) + .alignItems(horizontal = UiAlign.END, vertical = UiAlign.START), + ) { + Row(tags = listOf("graph-search"), modifier = Modifier.alignItems(vertical = UiAlign.CENTER)) { + Image(SearchIcon, tags = listOf("graph-search-icon")) + TextField( + value = search.query, + placeholder = graphEditorText("search"), + onChange = controller::searchFor, + id = field, + tags = listOf("graph-search-input"), + modifier = Modifier.size(170.px, UiLength.Auto).onKeyInput { input -> + val back = input.modifiers and GLFW.GLFW_MOD_SHIFT != 0 + when { + input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER -> + controller.stepSearch(if (back) -1 else 1) + + HollowKeys.NEXT_FOUND.matches(input) -> controller.stepSearch(1) + HollowKeys.PREVIOUS_FOUND.matches(input) -> controller.stepSearch(-1) + input.key == GLFW.GLFW_KEY_ESCAPE -> close() + else -> return@onKeyInput + } + input.consume() + }, + ) + val count = when { + search.query.isBlank() -> "" + found.isEmpty() -> graphEditorText("search_none") + else -> "${search.index.coerceIn(0, found.size - 1) + 1}/${found.size}" + } + Text(count, tags = listOfNotNull("graph-search-count", "missing".takeIf { search.query.isNotBlank() && found.isEmpty() })) + SearchButton("${field}-previous", PreviousIcon, HollowKeys.PREVIOUS_FOUND.hint(graphEditorText("search_previous"))) { + controller.stepSearch(-1) + } + SearchButton("${field}-next", NextIcon, HollowKeys.NEXT_FOUND.hint(graphEditorText("search_next"))) { + controller.stepSearch(1) + } + SearchButton("${field}-close", CloseIcon, graphEditorText("search_close"), ::close) + } + } + LaunchedEffect(search.focusRequests) { focus.request(field) } +} + +@Composable +private fun SearchButton(id: String, icon: String, tooltip: String, onPress: () -> Unit) { + Box( + id = id, + tags = listOf("graph-search-button"), + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .tooltipOnHover(tooltip) + .onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onPress() + event.consume() + }, + ) { + Image(icon, tags = listOf("graph-search-button-icon")) + } +} + +private const val SearchIcon = "hollowengine:textures/gui/icons/search.svg" +private const val PreviousIcon = "hollowengine:textures/gui/icons/chevron_left.svg" +private const val NextIcon = "hollowengine:textures/gui/icons/chevron_right.svg" +private const val CloseIcon = "hollowengine:textures/gui/icons/cross.svg" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewState.kt index b956785cd..6ea246dd2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewState.kt @@ -39,6 +39,14 @@ class GraphViewState( fun toGraphX(x: Float) = (x - panX) / zoom fun toGraphY(y: Float) = (y - panY) / zoom + /** Whether any of [rect] comes within [margin] of a canvas of the size [width] by [height]. */ + fun sees(rect: GraphRect, width: Float, height: Float, margin: Float): Boolean { + val left = toCanvasX(rect.x) + val top = toCanvasY(rect.y) + return left + rect.width * zoom > -margin && left < width + margin && + top + rect.height * zoom > -margin && top < height + margin + } + /** Zooms by [step] times, keeping the graph point under ([aroundX], [aroundY]) of the canvas in place. */ fun zoomBy(step: Float, aroundX: Float, aroundY: Float) { anchorCanvasX = aroundX diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt index fd17a2778..c90b2f4d5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/GizmoToolbar.kt @@ -4,10 +4,13 @@ import androidx.compose.runtime.Composable import ru.hollowhorizon.hollowengine.client.editor.GizmoEditMode import ru.hollowhorizon.hollowengine.client.editor.GizmoOrientation import ru.hollowhorizon.hollowengine.client.editor.GizmoSettings +import ru.hollowhorizon.hollowengine.client.editor.GizmoSession import ru.hollowhorizon.hollowengine.client.keymap.HollowKeyAction import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.inputTransparent import ru.hollowhorizon.hollowengine.client.ui.widgets.IconButton -import ru.hollowhorizon.hollowengine.client.ui.widgets.OrbitPivot import ru.hollowhorizon.hollowengine.client.utils.lang /** A manipulator of the gizmo as a toolbar shows it: its icon, its name and the key that starts it. */ @@ -40,22 +43,29 @@ internal val gizmoOrientationTooltip: String * and the world share them, so switching one switches all. */ @Composable -internal fun GizmoToolbarButtons(iconSize: Float = 12f) { +internal fun GizmoToolbarButtons(id: String, iconSize: Float = 12f) { GizmoModeButtons.forEach { button -> - IconButton(button.icon, button.label, active = button.mode in GizmoSettings.modes, iconSize = iconSize) { + IconButton( + button.icon, button.label, id = "$id-mode-${button.mode.name.lowercase()}", + active = button.mode in GizmoSettings.modes, iconSize = iconSize, + ) { GizmoSettings.toggle(button.mode) } } - GizmoOrientationButton(iconSize) - IconButton( - OrbitSelectionIcon, - HollowKeys.ORBIT_PIVOT.hint("hollowengine.gui.ide.gizmo.orbit_selection".lang), - active = OrbitPivot.aroundSelection, - iconSize = iconSize, - ) { OrbitPivot.aroundSelection = !OrbitPivot.aroundSelection } + GizmoOrientationButton(iconSize, id = "$id-orientation") } -private const val OrbitSelectionIcon = "hollowengine:textures/gui/icons/orbit_selection.svg" +@Composable +internal fun GizmoRotationHint(session: GizmoSession, modifier: Modifier = Modifier) { + val transform = session.keyboard ?: return + if (transform.mode != GizmoEditMode.ROTATE || transform.axis != null) return + val mode = transform.rotationMode.name.lowercase() + Text( + HollowKeys.ROTATE.hint("hollowengine.gui.ide.gizmo.rotation.$mode".lang), + tags = listOf("viewport-transform-hint"), + modifier = modifier.inputTransparent(), + ) +} @Composable internal fun GizmoOrientationButton(iconSize: Float = 12f, id: String? = null, tags: List = emptyList()) = diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeCodeRename.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeCodeRename.kt new file mode 100644 index 000000000..61266412d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeCodeRename.kt @@ -0,0 +1,169 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.* +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.history.UndoLabel +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.TextFieldState +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCaret +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.files.DirectoryManager.fromReadablePath +import ru.hollowhorizon.hollowengine.common.scripting.ide.FileRename +import java.io.IOException + +/** The name being typed for the declaration at [caret] of [path], whose text was [text] when F2 was pressed. */ +internal class CodeRenameDialog( + val path: String, + val text: String, + val caret: Int, + val original: String, + val x: Float, + val y: Float, +) { + var name by mutableStateOf(original) +} + +/** F2 on a name in the code: renames the declaration and what refers to it through every script of the project. */ +internal class HollowIdeCodeRename( + private val model: HollowIdeModel, + private val sessionOf: (String) -> HollowIdeEditorSession, + private val editorOf: (String) -> TextFieldState?, + private val changed: (HollowIdeOpenFile) -> Unit, + private val setStatus: (String) -> Unit, +) { + var dialog by mutableStateOf(null) + private set + + /** Asks what the caret of [file] names, and opens the dialog at [x], [y] once it is known to be renamable. */ + fun start(file: HollowIdeOpenFile, caret: Int, x: Float, y: Float): Boolean { + if (file.readOnly) return false + val text = file.text + sessionOf(file.path).resolveRenameTarget(text, caret) { target -> + if (target == null) setStatus(LANG_UNAVAILABLE.lang) + else dialog = CodeRenameDialog(file.path, text, caret, target.name, x, y) + } + return true + } + + fun cancel() { + dialog = null + } + + fun confirm() { + val open = dialog ?: return + val name = open.name.trim() + if (!NAME.matches(name)) { + setStatus(LANG_INVALID.lang(name)) + return + } + dialog = null + if (name == open.original) return + val documents = + model.files.values.filterNot { it.readOnly }.mapNotNull { file -> file.textOrNull?.let { file.path to it } } + .toMap() + sessionOf(open.path).rename(open.text, open.caret, name, documents) { renames -> apply(open, name, renames) } + } + + private fun apply(open: CodeRenameDialog, name: String, renames: List) { + if (renames.isEmpty()) { + setStatus(LANG_NOTHING.lang(open.original)) + return + } + val label = UndoLabel(LANG_UNDO, open.original, name) + val done = renames.filter { rename -> write(rename, label) } + val skipped = renames.size - done.size + val count = done.sumOf { it.edits.size } + setStatus( + if (skipped == 0) LANG_DONE.lang(count, done.size) + else LANG_PARTLY.lang(count, done.size, skipped) + ) + } + + /** Writes [rename] into the open editor of its file, or onto the disk; false when the text changed meanwhile. */ + private fun write(rename: FileRename, label: UndoLabel): Boolean { + val next = rename.applied() + val open = model.files[rename.path] + if (open?.textOrNull != null) { + if (open.readOnly || open.text.unixLines() != rename.text) return false + editorOf(rename.path)?.let { editor -> + editor.applyEdit(next, listOf(UiTextCaret(shifted(editor.caret, rename))), label) + } + model.updateText(rename.path, next) + changed(open) + return true + } + return try { + val file = rename.path.fromReadablePath() + val disk = file.readText() + if (disk.unixLines() != rename.text) return false + writeIdeFile(file.toPath(), (if ("\r\n" in disk) next.replace("\n", "\r\n") else next).toByteArray()) + true + } catch (exception: IOException) { + HollowEngine.LOGGER.error("Could not rename in {}", rename.path, exception) + false + } + } + + /** Where [caret] lands once the edits of [rename] before it are made. */ + private fun shifted(caret: Int, rename: FileRename): Int = + caret + rename.edits.filter { it.end <= caret }.sumOf { it.replacement.length - (it.end - it.start) } + + private fun String.unixLines() = replace("\r\n", "\n").replace('\r', '\n') + + private companion object { + val NAME = Regex("[A-Za-z_][A-Za-z0-9_]*") + const val LANG = "hollowengine.gui.ide.editor.rename" + const val LANG_TITLE = "$LANG.title" + const val LANG_UNAVAILABLE = "$LANG.unavailable" + const val LANG_INVALID = "$LANG.invalid" + const val LANG_NOTHING = "$LANG.nothing" + const val LANG_DONE = "$LANG.done" + const val LANG_PARTLY = "$LANG.partly" + const val LANG_UNDO = "${UndoLabel.LANG}.code.rename" + } + + /** The field the new name is typed in, at where F2 was pressed. */ + @Composable + fun Dialog() { + val open = dialog ?: return + val field = remember(open) { + TextFieldState(initialText = open.original).apply { + setSelection( + 0, + open.original.length + ) + } + } + Popup( + anchorBounds = UiRect(open.x, open.y, 0f, 0f), + alignment = UiPopupAlignment.Cursor, + id = "code-rename-dialog", + tags = listOf("dropdown-popup", "project-name-dialog"), + modal = true, + onDismiss = ::cancel, + ) { + val focus = LocalUiFocusRequester.current + LaunchedEffect(open) { focus.request(INPUT) } + Text(LANG_TITLE.lang(open.original), tags = listOf("project-name-dialog-title")) + TextField( + value = open.name, + onChange = { open.name = it }, + state = field, + id = INPUT, + tags = listOf("project-name-dialog-input"), + modifier = Modifier.size(180.px, 24.px).onKeyInput { input -> + when (input.key) { + GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> confirm() + GLFW.GLFW_KEY_ESCAPE -> cancel() + else -> return@onKeyInput + } + input.consume() + }, + ) + } + } +} + +private const val INPUT = "code-rename-input" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeContributions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeContributions.kt index 8972fa1c8..3ce347275 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeContributions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeContributions.kt @@ -25,6 +25,7 @@ internal class HollowIdeContributions( private val dock: DockingState, private val context: HollowIdeContext, private val onLanguagesChanged: () -> Unit, + private val onPreviewsChanged: () -> Unit, ) { /** Read by compositions that list contributions, so they are rebuilt when an addon comes or goes. */ var revision by mutableIntStateOf(0) @@ -46,6 +47,7 @@ internal class HollowIdeContributions( HollowIdeExtensionPoints.MENU_ITEMS.observeOnClient {} HollowIdeExtensionPoints.PROJECT_ACTIONS.observeOnClient {} HollowIdeExtensionPoints.LANGUAGES.observeOnClient { onLanguagesChanged() } + HollowIdeExtensionPoints.PREVIEWS.observeOnClient { onPreviewsChanged() } HollowIdeExtensionPoints.FILE_TYPES.registrations.forEach(::installFileType) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt index 53ddeca84..9ab612720 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt @@ -20,6 +20,8 @@ import kotlin.time.Duration.Companion.milliseconds internal class HollowIdeEditorSession( private val path: String, + private val typedProperties: String? = null, + private val placeProperties: String? = null, private val onUpdated: () -> Unit, ) { private val languageService = languageServiceForPath(path) @@ -49,6 +51,7 @@ internal class HollowIdeEditorSession( private var completionJob: Job? = null private var definitionJob: Job? = null private var gutterJob: Job? = null + private var renameJob: Job? = null private var formatJob: Job? = null @Volatile @@ -165,6 +168,15 @@ internal class HollowIdeEditorSession( val signatures: UiSignatureHelpProvider get() = codeInsight.signatures val hover: UiHoverInfoProvider get() = codeInsight.hover + /** What analysis made of exactly [text], once it has it; nothing until then. */ + fun exactAnalysis(text: String): KotlinFragmentAnalysis? { + val analyzer = currentAnalyzer() + val current = snapshot + if (current.matchesText(text, analyzer)) return current.fragment + requestAnalysis(text, analyzer) + return null + } + fun inlayHints(text: String): List { val analyzer = currentAnalyzer() val current = snapshot @@ -196,7 +208,7 @@ internal class HollowIdeEditorSession( val current = snapshot if (current.matchesText(text, analyzer)) return current.structure requestAnalysis(text, analyzer) - return EditorStructure(folds = shiftCodeFoldsThroughEdit(current.text, text, current.structure.folds)) + return shiftEditorStructureThroughEdit(current.text, text, current.structure) } val canFormat: Boolean get() = currentAnalyzer().canFormat @@ -238,6 +250,43 @@ internal class HollowIdeEditorSession( } } + /** Tells [onResolved], on the client thread, what name at [caret] can be renamed. */ + fun resolveRenameTarget(text: String, caret: Int, onResolved: (RenameTarget?) -> Unit) { + val analyzer = currentAnalyzer() + val publicationDispatcher = Minecraft.getInstance().dispatcher + renameJob?.cancel() + renameJob = scope.launch { + val target = runCatching { analyzer.renameTarget(path, text, caret.coerceIn(0, text.length)) } + .getOrElse { failure -> + reportAnalysisFailure("rename target", failure) + null + } + withContext(publicationDispatcher) { onResolved(target) } + } + } + + /** Tells [onRenamed], on the client thread, what renaming the name at [caret] to [newName] changes in each file. */ + fun rename( + text: String, + caret: Int, + newName: String, + documents: Map, + onRenamed: (List) -> Unit, + ) { + val analyzer = currentAnalyzer() + val publicationDispatcher = Minecraft.getInstance().dispatcher + renameJob?.cancel() + renameJob = scope.launch { + val renames = runCatching { + analyzer.rename(path, text, caret.coerceIn(0, text.length), newName, documents) + }.getOrElse { failure -> + reportAnalysisFailure("rename", failure) + emptyList() + } + withContext(publicationDispatcher) { onRenamed(renames) } + } + } + fun resolveGutterTargets(text: String, offset: Int, onResolved: (List) -> Unit) { val analyzer = currentAnalyzer() val publicationDispatcher = Minecraft.getInstance().dispatcher @@ -273,7 +322,11 @@ internal class HollowIdeEditorSession( } fun close() { + if (!scope.isActive) return scope.cancel() + analysisDispatcher.executor.execute { + runCatching { currentAnalyzer().forget(path) }.onFailure { reportAnalysisFailure("forget", it) } + } analysisDispatcher.close() } @@ -309,6 +362,12 @@ internal class HollowIdeEditorSession( reportAnalysisFailure("structure", failure) EditorStructure() }, + propertyTypes = typedProperties?.let { prefix -> + runCatching { analyzer.propertyTypes(path, text, prefix, placeProperties) }.getOrElse { failure -> + reportAnalysisFailure("types", failure) + KotlinPropertyTypes() + } + } ?: KotlinPropertyTypes(), ) publishAnalysisIfCurrent(requestRevision) { snapshot = next @@ -341,6 +400,14 @@ internal class HollowIdeEditorSession( val requestRevision = completionRevision.incrementAndGet() completionJob?.cancel() completionJob = scope.launch { + val acceptedTypes = runCatching { + analyzer.acceptedExpressionTypes(path, text, key.caret, ContextSnippetTypes) + }.getOrElse { failure -> + reportAnalysisFailure("expected types", failure) + null + } + requestContextCompletions(text, key, analyzer, requestRevision, acceptedTypes) + val collected = ArrayList() var publishedAt = 0L var publishedSize = -1 @@ -366,11 +433,20 @@ internal class HollowIdeEditorSession( } if (publishedSize != collected.size) publish() } + } + + private fun requestContextCompletions( + text: String, + key: CompletionKey, + analyzer: ScriptingAnalyzer, + requestRevision: Long, + acceptedTypes: List?, + ) { contextCompletionJob?.cancel() contextCompletionJob = scope.launch(Minecraft.getInstance().dispatcher) { val context = UiCompletionContext(text, key.caret) val items = runCatching { - val candidates = ideContextCompletions(path, context).toMutableList() + val candidates = ideContextCompletions(path, context, acceptedTypes).toMutableList() IdeCompletionEvent.post(IdeCompletionEvent(path, text, key.caret, candidates)).items.toList() }.getOrElse { failure -> HollowEngine.LOGGER.error("Context completions failed for '{}'", path, failure) @@ -502,7 +578,10 @@ private data class EditorAnalysisSnapshot( val inlayHints: List, val diagnostics: List, val structure: EditorStructure = EditorStructure(), + val propertyTypes: KotlinPropertyTypes = KotlinPropertyTypes(), ) { + val fragment = KotlinFragmentAnalysis(highlights, diagnostics, propertyTypes) + fun matchesText(text: String, analyzer: ScriptingAnalyzer): Boolean { return textHash == text.hashCode() && textLength == text.length && this.analyzer === analyzer } @@ -637,6 +716,25 @@ private const val OccurrenceBackgroundAlpha = 0.24f private val OccurrenceFallbackBackground = UiColor(0.66f, 0.72f, 0.78f, OccurrenceBackgroundAlpha) private val OccurrenceFallbackStyle = UiInlineStyle().withBackground(OccurrenceFallbackBackground) +/** + * [structure] of [originalText] as it applies to [editedText]: what lies before or after the edit moves with the + * text, and what the edit touched waits for the next analysis. + */ +internal fun shiftEditorStructureThroughEdit(originalText: String, editedText: String, structure: EditorStructure): EditorStructure { + if (originalText == editedText) return structure + val edit = uiTextEditSpan(originalText, editedText) + fun shifted(offset: Int): Int? = when { + offset < edit.oldStart -> offset + offset >= edit.oldEnd -> offset + edit.delta + else -> null + } + return EditorStructure( + folds = shiftCodeFoldsThroughEdit(originalText, editedText, structure.folds), + markers = structure.markers.mapNotNull { marker -> shifted(marker.offset)?.let { marker.copy(offset = it) } }, + runTargets = structure.runTargets.mapNotNull { target -> shifted(target.offset)?.let { target.copy(offset = it) } }, + ) +} + internal fun shiftDiagnosticsForEditedText( originalText: String, editedText: String, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt index ae19a2718..9c16a51f1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints import ru.hollowhorizon.hollowengine.client.ui.docking.DockPlacement import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeLanguageService +import ru.hollowhorizon.hollowengine.client.ui.ide.preview.HollowIdeFilePreview import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensions import ru.hollowhorizon.hollowengine.common.addons.HollowAddonRegistration import ru.hollowhorizon.hollowengine.common.utils.rl @@ -19,6 +20,8 @@ object HollowIdeExtensionPoints { val FILE_ACTIONS = ExtensionPoints.create("hollowengine:ide/file-actions".rl, HollowIdeFileActionProvider::class) val PROJECT_ACTIONS = ExtensionPoints.create("hollowengine:ide/project-actions".rl, HollowIdeProjectActionProvider::class) val LANGUAGES = ExtensionPoints.create("hollowengine:ide/languages".rl, HollowIdeLanguageService::class) + val SCRIPT_TEMPLATES = ExtensionPoints.create("hollowengine:ide/script-templates".rl, HollowIdeScriptTemplate::class) + val PREVIEWS = ExtensionPoints.create("hollowengine:ide/previews".rl, HollowIdeFilePreview::class) } /** Operations a contributed panel or menu action may request from the IDE. */ @@ -157,3 +160,15 @@ fun HollowAddonExtensions.registerIdeLanguage( language: HollowIdeLanguageService, priority: Int = 0, ): HollowAddonRegistration = register(HollowIdeExtensionPoints.LANGUAGES, language.id, language, priority) + +/** Adds a view of the files [preview] matches, with its own button next to the text in the tab bar. */ +fun HollowAddonExtensions.registerIdePreview( + preview: HollowIdeFilePreview, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.PREVIEWS, preview.id, preview, priority) + +/** Adds a kind of script to the "New script" menu of the project tree. */ +fun HollowAddonExtensions.registerIdeScriptTemplate( + template: HollowIdeScriptTemplate, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.SCRIPT_TEMPLATES, template.id, template, priority) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt index 401910285..39c7b56f5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt @@ -46,7 +46,7 @@ class HollowIdeFileType( internal fun matchesPath(path: String): Boolean? = pathMatcher?.invoke(path) fun open(path: String, bytes: ByteArray): HollowIdeOpenFile = - HollowIdeOpenFile(path, this, loader(path, bytes)) + HollowIdeOpenFile(path, this, loader(path, bytes), bytes) companion object { fun extensions( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt new file mode 100644 index 000000000..98f9a21ec --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileWatcher.kt @@ -0,0 +1,57 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import kotlinx.coroutines.* +import ru.hollowhorizon.hollowengine.HollowEngine +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* + +/** Watches parent directories so editors that save by replacing the file are observed too. */ +internal suspend fun watchIdeFiles(paths: List, onChanged: () -> Unit) = withContext(Dispatchers.IO) { + if (paths.isEmpty()) return@withContext + try { + while (currentCoroutineContext().isActive) { + try { + FileSystems.getDefault().newWatchService().use { watcher -> + val watched = paths.asSequence() + .flatMap { path -> + val absolute = path.toAbsolutePath().normalize() + val target = runCatching { absolute.toRealPath() }.getOrDefault(absolute) + listOf(absolute.watchTarget(), target.watchTarget()) + } + .distinct().groupBy(Path::getParent) + .map { (directory, files) -> + directory.register( + watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY + ) to files.map(Path::getFileName).toSet() + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, names) -> names.flatten().toSet() } + + onChanged() + while (watched.isNotEmpty() && currentCoroutineContext().isActive) { + val key = runInterruptible { watcher.take() } + val names = watched[key].orEmpty() + val changed = key.pollEvents().any { it.kind() == OVERFLOW || it.context() in names } + if (!key.reset() || changed) break + } + } + } catch (_: NoSuchFileException) { + onChanged() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + HollowEngine.LOGGER.warn("Could not watch IDE files", error) + } +} + +/** A missing parent is observed through the nearest existing ancestor until it is recreated. */ +private fun Path.watchTarget(): Path { + var target = this + while (!Files.isDirectory(target.parent)) target = target.parent + return target +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt index cdcb19c6d..a6d15740a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGutter.kt @@ -2,6 +2,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.ui.widgets.UiGutterMarker import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.scripting.execution.ScriptExecutionStatus @@ -22,11 +23,12 @@ internal fun ideGutterMarkers( buildList { structure.markers.forEach { marker -> val kind = marker.kind.name.lowercase() + val icon = marker.kind.icon ?: kind add(UiGutterMarker("$kind-${marker.offset}", marker.offset, - "hollowengine:textures/gui/icons/gutter/$kind.svg", "hollowengine.gui.ide.gutter.$kind".lang, + "hollowengine:textures/gui/icons/gutter/$icon.svg", "hollowengine.gui.ide.gutter.$kind".lang(*marker.args.toTypedArray()), listOf("gutter-$kind"), onClick = if (!marker.kind.navigable) null else { { if (file.text == analyzedText) navigate(marker.offset) } - })) + }, tint = marker.color?.let { UiColor.fromArgb(it) })) } if (canRun) structure.runTargets.forEach { target -> val status = execution?.status ?: ScriptExecutionStatus.IDLE diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeHideToolbarDialog.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeHideToolbarDialog.kt index 6c761f36a..306dd342d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeHideToolbarDialog.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeHideToolbarDialog.kt @@ -6,7 +6,7 @@ import ru.hollowhorizon.hollowengine.client.ui.widgets.Button import ru.hollowhorizon.hollowengine.client.ui.widgets.UiButtonKind import ru.hollowhorizon.hollowengine.client.utils.lang -internal val CenteredOnViewport = UiPopupAlignment( +val CenteredOnViewport = UiPopupAlignment( anchorHorizontal = UiAlign.CENTER, anchorVertical = UiAlign.CENTER, popupHorizontal = UiAlign.CENTER, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeKeymapDialog.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeKeymapDialog.kt index d2f0c1dc7..c76a66936 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeKeymapDialog.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeKeymapDialog.kt @@ -70,6 +70,7 @@ internal fun HollowIdeKeymapDialog(visible: Boolean, onDismiss: () -> Unit) { } } SearchField(query) { query = it } + LaunchedEffect(Unit) { focus.request("ide-keymap-search") } Column( tags = listOf("ide-keymap-list"), modifier = Modifier.size(100.percent, UiLength.Fit).maxSize( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt index 2d3d037c9..2c5151aca 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt @@ -117,7 +117,8 @@ internal class HollowIdeModel( return HollowIdeOpenResult.File(openReadOnly(path, file.readText()), created) } files[path]?.let { opened -> - if (!opened.dirty && opened.type.requiresContent) opened.refresh(file.readBytes()) + if (opened.textFileSync != null) refreshExternalTextFile(opened) + else if (!opened.dirty && opened.type.requiresContent) opened.refresh(file.readBytes()) return HollowIdeOpenResult.File(opened, created = false) } val opened = runCatching { @@ -172,7 +173,15 @@ internal class HollowIdeModel( val file = files[path]?.takeUnless { it.readOnly } ?: return false pendingSaves.remove(path)?.cancel() try { - writeIdeFile(path.fromReadablePath().toPath(), file.encode()) + val sync = file.textFileSync + if (sync != null) { + if (refreshExternalTextFile(file)) return false + if (!file.dirty) return true + if (!sync.save(file.encode(), sync.revision())) { + refreshExternalTextFile(file) + return false + } + } else writeIdeFile(path.fromReadablePath().toPath(), file.encode()) } catch (e: Exception) { HollowEngine.LOGGER.error("Could not save {}", path, e) HollowNotifications.error("hollowengine.gui.ide.file.save_failed".lang.format(path.substringAfterLast('/'))) @@ -337,19 +346,26 @@ internal class HollowIdeModel( .forEach { file -> val source = file.path.fromReadablePath() if (!source.isFile) return@forEach - runCatching { file.refresh(source.readBytes()) }.onFailure { - HollowEngine.LOGGER.warn( - "Could not read '{}' again: {}", - file.path, - it.message - ) - } + runCatching { + if (file.textFileSync != null) refreshExternalTextFile(file) + else file.refresh(source.readBytes()) + }.onFailure { + HollowEngine.LOGGER.warn( + "Could not read '{}' again: {}", file.path, it.message + ) + } } } - /** Reconcile once on return from another application, without polling the filesystem per frame. */ + /** Reconcile on a file notification or on return from another application. */ fun refreshExternalFiles() { tree.refresh() + files.values.toList().filter { it.textFileSync != null }.forEach { file -> + if (!file.path.fromReadablePath().isFile) return@forEach + runCatching { refreshExternalTextFile(file) }.onFailure { error -> + HollowEngine.LOGGER.warn("Could not reload IDE file '{}'", file.path, error) + } + } files.values.toList().filter { !it.readOnly && !it.path.fromReadablePath().exists() }.forEach { file -> pendingSaves.remove(file.path)?.cancel() if (!file.dirty) { @@ -448,19 +464,44 @@ internal class HollowIdeModel( private fun scheduleSave(path: String) { val file = files[path]?.takeIf { it.dirty } ?: return val text = file.textOrNull ?: return + val sync = file.textFileSync ?: return + val revision = sync.revision() pendingSaves.remove(path)?.cancel() - pendingSaves[path] = ioScope.launch { + val job = ioScope.launch(start = CoroutineStart.LAZY) { delay(AutoSaveDelayMillis) + val context = currentCoroutineContext() runCatching { - writeIdeFile(path.fromReadablePath().toPath(), text.toByteArray(Charsets.UTF_8)) - }.onSuccess { + sync.save(text.toByteArray(Charsets.UTF_8), revision, context::ensureActive) + }.onSuccess { saved -> Minecraft.getInstance().execute { + if (files[path] !== file || pendingSaves[path] !== context[Job]) return@execute pendingSaves.remove(path) - files[path]?.markSavedIfText(text) + if (saved) file.markSavedIfText(text) + else runCatching { refreshExternalTextFile(file) }.onFailure { error -> + HollowEngine.LOGGER.warn("Could not reload IDE file '{}' after autosave", path, error) + } tree.refresh() } + }.onFailure { error -> + if (error !is CancellationException) HollowEngine.LOGGER.warn( + "Could not autosave IDE file '{}'", + path, + error + ) } } + pendingSaves[path] = job + job.start() + } + + /** Reloads [file] when it changed on disk; the change wins over unsaved edits, and the user is told they went. */ + private fun refreshExternalTextFile(file: HollowIdeOpenFile): Boolean { + val unsaved = file.dirty + val changed = file.reloadExternalText() + if (!changed) return false + pendingSaves.remove(file.path)?.cancel() + if (unsaved) HollowNotifications.warning("hollowengine.gui.ide.file.external_change_discarded".lang.format(file.title)) + return true } } @@ -479,9 +520,12 @@ class HollowIdeOpenFile internal constructor( val path: String, val type: HollowIdeFileType, val document: HollowIdeFileDocument, + initialContent: ByteArray = document.encode(), ) { var dirty by mutableStateOf(false) private set + internal var externalTextRevision by mutableStateOf(0L) + private set private var saveHandler: (() -> Boolean)? = null val readOnly: Boolean get() = document.readOnly @@ -490,6 +534,9 @@ class HollowIdeOpenFile internal constructor( internal val textOrNull: String? get() = (document as? HollowIdeTextDocument)?.text val id: String = fileDockItemId(path) val title: String get() = path.substringAfterLast('/').ifEmpty { path } + internal val textFileSync = if (document is HollowIdeTextDocument && !readOnly && !virtual) { + HollowIdeTextFileSync(path.fromReadablePath().toPath(), initialContent) + } else null fun dockItem(): DockItem { return DockItem(id, title, IconHelper.forPath(path).toString(), dirty = dirty && !readOnly) @@ -530,6 +577,12 @@ class HollowIdeOpenFile internal constructor( document.reload(bytes) } + internal fun reloadExternalText(): Boolean = textFileSync?.reloadIfChanged { bytes -> + document.reload(bytes) + dirty = false + externalTextRevision++ + } == true + fun refreshReadOnly(next: String) { val textDocument = document as? HollowIdeTextDocument ?: return if (!readOnly) return diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt index 2b2e07bf7..10f9de6df 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt @@ -8,6 +8,7 @@ import org.lwjgl.glfw.GLFW import org.lwjgl.opengl.GL11 import org.lwjgl.opengl.GL30 import ru.hollowhorizon.hollowengine.client.history.UndoKeys +import ru.hollowhorizon.hollowengine.client.history.UndoLabel import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.docking.* @@ -24,7 +25,9 @@ import ru.hollowhorizon.hollowengine.client.ui.ide.history.IdeHistories import ru.hollowhorizon.hollowengine.client.ui.ide.panels.* import ru.hollowhorizon.hollowengine.client.ui.ide.preview.* import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineDock +import ru.hollowhorizon.hollowengine.client.ui.inspector.AssetPickerDialog import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorSelection +import ru.hollowhorizon.hollowengine.client.ui.inspector.LocalInspectorHost import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.ui.render.MinecraftUiRenderer import ru.hollowhorizon.hollowengine.client.ui.render.UiRenderTarget @@ -45,6 +48,7 @@ import ru.hollowhorizon.hollowengine.common.utils.DesktopUtil import ru.hollowhorizon.hollowengine.common.utils.isProduction import ru.hollowhorizon.hollowengine.common.utils.nbt.TagModuleRevision import java.io.File +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.milliseconds /** A file dragged out of the project tree; anything that accepts drops can look for this payload. */ @@ -158,9 +162,9 @@ object HollowIdeOverlay { private var fileContextMenu by mutableStateOf(null) private var gutterNavigation by mutableStateOf>(emptyList()) private val dragAndDrop = UiDragAndDropState() - private var refreshFilesRequested = false + private val refreshFilesRequested = AtomicBoolean() private val externalFiles = HollowIdeExternalFiles( - onFocusGained = { refreshFilesRequested = true }, + onFocusGained = { refreshFilesRequested.set(true) }, onMove = { files, x, y -> updateExternalDrag(files, x, y) }, onLeave = { pipeline.await() @@ -182,6 +186,13 @@ object HollowIdeOverlay { ) private val findStates = mutableStateMapOf() private val search = HollowIdeSearchController { statusText = it } + private val codeRename = HollowIdeCodeRename( + model = model, + sessionOf = ::editorSession, + editorOf = editorStates::get, + changed = { dock.updateItem(it.dockItem()) }, + setStatus = { statusText = it }, + ) private var colorPicker by mutableStateOf(null) private val ideContext = object : HollowIdeContext { override val focusedFile: HollowIdeOpenFile? get() = this@HollowIdeOverlay.focusedFile() @@ -207,18 +218,34 @@ object HollowIdeOverlay { statusText = message } } - private val contributions = HollowIdeContributions(fileTypes, model, dock, ideContext) { - editorSessions.values.forEach(HollowIdeEditorSession::close) - editorSessions.clear() - editorAnalysisRevision++ - } + private val contributions = HollowIdeContributions( + fileTypes, + model, + dock, + ideContext, + onLanguagesChanged = { + editorSessions.values.forEach(HollowIdeEditorSession::close) + editorSessions.clear() + editorAnalysisRevision++ + }, + onPreviewsChanged = { + fileViews.values.forEach(HollowIdeFileView::close) + fileViews.clear() + }, + ) private fun editorState(file: HollowIdeOpenFile): TextFieldState = editorStates.getOrPut(file.path) { TextFieldState( initialText = file.text, multiline = true, pasteTransformer = KotlinStringPasteTransformer.takeIf { file.path.isKotlinSource() }, - ) + ).also { state -> + // An undo made away from the text editor, from a preview or the history window, still reaches the file. + state.onHistoryRestored = { text -> + model.updateText(file.path, text) + model.files[file.path]?.let { dock.updateItem(it.dockItem()) } + } + } } private fun editorSession(path: String): HollowIdeEditorSession = editorSessions.getOrPut(path) { @@ -567,9 +594,8 @@ object HollowIdeOverlay { } externalFiles.update(event.minecraft.window.window, isVisible() && !collapsed) if (!isVisible()) return - if (refreshFilesRequested && !externalFiles.active) { + if (!externalFiles.active && refreshFilesRequested.getAndSet(false)) { pipeline.await() - refreshFilesRequested = false model.refreshExternalFiles() } AssetManagerLifecycle.observe(event.minecraft) @@ -670,7 +696,7 @@ object HollowIdeOverlay { dock.close(id) editorSessions.remove(path)?.close() editorStates.remove(path) - fileViews.remove(path) + fileViews.remove(path)?.close() if (activeEditorPath == path) activeEditorPath = null findStates.remove(path) if (colorPicker?.path == path) colorPicker = null @@ -766,6 +792,7 @@ object HollowIdeOverlay { private fun Content() { RememberLayout() TrackActiveEditor() + WatchTextFiles() Box( id = "ide-root", modifier = Modifier.style("hollowengine:ui/styles/widgets.hss").style("hollowengine:ui/styles/ide.hss") @@ -786,6 +813,8 @@ object HollowIdeOverlay { )) if (handled) input.consume() }) { + IdeInspectorHost.pending?.let { request -> AssetPickerDialog(request) { IdeInspectorHost.pending = null } } + codeRename.Dialog() CompositionLocalProvider(LocalDragAndDrop provides dragAndDrop) { if (collapsed) { GearButton() @@ -835,6 +864,15 @@ object HollowIdeOverlay { } } + @Composable + private fun WatchTextFiles() { + val paths = + model.files.values.filter { it.textFileSync != null }.map { it.path.fromReadablePath().toPath() }.sorted() + LaunchedEffect(paths) { + if (paths.isNotEmpty()) watchIdeFiles(paths) { refreshFilesRequested.set(true) } + } + } + @Composable private fun RememberLayout() { val layout = dock.capture() @@ -868,11 +906,12 @@ object HollowIdeOverlay { private fun currentProblems(): HollowIdeProblems { val file = activeEditorFile() - val diagnostics = file?.let { editorSessions[it.path]?.diagnostics(it.text) }.orEmpty() + val found = file?.let(::fileView)?.problems.orEmpty() + val diagnostics = file?.let { editorSessions[it.path]?.diagnostics(it.text) }.orEmpty() + found.map { it.diagnostic } return HollowIdeProblems(file, diagnostics) { diagnostic -> if (file != null) { openFileDockItem(file) - focusEditorAt(file, diagnostic.start) + found.firstOrNull { it.diagnostic === diagnostic }?.reveal?.invoke() ?: focusEditorAt(file, diagnostic.start) } } } @@ -1027,10 +1066,7 @@ object HollowIdeOverlay { expanded = openDropdown == "windows", onExpandedChange = { openDropdown = if (it) "windows" else null }, items = hollowIdeWindowMenuItems( - model, - dock, - contributions.windowMenu(), - ::applyDefaultLayout + model, dock, contributions.windowMenu(), ::applyDefaultLayout ) + contributions.menuItems(HollowIdeMenu.WINDOW), ) UiDropdown( @@ -1163,6 +1199,7 @@ object HollowIdeOverlay { onCreateSoundEvents = project::createSoundEvents, onRename = project::openRenameDialog, onCopy = { project.copy(it, cut = false) }, + onCopyPath = project::copyPath, onCut = { project.copy(it, cut = true) }, onPaste = project::pasteInto, onShowInExplorer = project::showInExplorer, @@ -1170,8 +1207,7 @@ object HollowIdeOverlay { onDismiss = { project.closePopups() }, contributedActions = { menu -> contributions.projectActions( - menu.path, - menu.path.fromReadablePath().isDirectory + menu.path, menu.path.fromReadablePath().isDirectory ) }, ) @@ -1182,13 +1218,6 @@ object HollowIdeOverlay { onConfirm = project::applyNameDialog, onCancel = project::cancelNameDialog, ) - LaunchedEffect(dialog?.action, dialog?.path) { - if (dialog == null) return@LaunchedEffect - Minecraft.getInstance().execute { - pipeline.await() - surface.runtime.focus(ProjectNameDialogInputId) - } - } } } @@ -1196,6 +1225,9 @@ object HollowIdeOverlay { private fun FileEditor(file: HollowIdeOpenFile) { val editorSession = remember(file.path) { editorSession(file.path) } val editorState = editorState(file) + LaunchedEffect(file, file.externalTextRevision) { + editorState.setText(file.text, moveCaretToEnd = false) + } val analysisRevision = editorAnalysisRevision.toLong() + editorSession.revision val diagnostics = editorSession.diagnostics(file.text) val inlayHints = editorSession.inlayHints(file.text) @@ -1230,8 +1262,9 @@ object HollowIdeOverlay { modifier = Modifier.grow(1f).dropTarget( accepts = { !file.readOnly && it.payload is HollowIdeFileDrag }, onDragOver = { _, x, y -> - editorState.offsetAtPoint?.invoke(x, y)?.let(editorState::moveCaret) + editorState.previewDropAt(x, y) }, + onDragExit = editorState::clearDropPreview, onDrop = { item, x, y -> val dropped = item.payload as? HollowIdeFileDrag ?: return@dropTarget false insertFileReference(file, editorState, dropped.path, x, y) @@ -1245,18 +1278,20 @@ object HollowIdeOverlay { editorSession.requestAnalysis(text, text.length) dock.updateItem(file.dockItem()) }, - highlighter = editorSession.highlighter, - completions = if (file.readOnly) null else editorSession.completions, - signatureHelp = editorSession.signatures, - hoverInfo = editorSession.hover, - diagnostics = diagnostics, - folds = structure.folds, - gutterMarkers = gutterMarkers, - searchMatches = matches, - activeSearchMatch = find?.let { matches.getOrNull(it.currentIndex) }, - inlayHints = inlayHints, - inlayRevision = analysisRevision, - onInlayAction = { action -> runInlayAction(file, action) }, + insight = UiCodeInsight( + highlighter = editorSession.highlighter, + completions = if (file.readOnly) null else editorSession.completions, + signatureHelp = editorSession.signatures, + hover = editorSession.hover, + diagnostics = diagnostics, + inlayHints = inlayHints, + onInlayAction = { action -> runInlayAction(file, action) }, + completionRevision = analysisRevision, + inlayRevision = analysisRevision, + ), + view = UiCodeView(folds = structure.folds, markers = gutterMarkers), + search = UiTextSearch(matches, find?.let { matches.getOrNull(it.currentIndex) }), + syntax = KotlinCodeSyntax.takeIf { file.path.isKotlinSource() }, readOnly = file.readOnly, fontSize = fontSize, state = editorState, @@ -1282,17 +1317,25 @@ object HollowIdeOverlay { file.type.editor(file) return } - val context = remember(file, view) { - HollowIdePreviewContext(file, view) { next -> - applyEditorText(file, next, editorState(file).caret.coerceAtMost(next.length)) + val preview = view.preview + val context = remember(file, view, preview) { + HollowIdePreviewContext(file, view, preview, editorState(file).history) { next, label, mergeKey -> + applyEditorText(file, next, editorState(file).caret.coerceAtMost(next.length), label, mergeKey) } } - HollowIdeFileViewBody( - view = view, - id = file.id, - text = { file.type.editor(file) }, - preview = { view.preview.content(context) }, - ) + Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { + HollowIdeFileViewBody( + view = view, + id = file.id, + text = { file.type.editor(file) }, + preview = { + CompositionLocalProvider(LocalInspectorHost provides IdeInspectorHost) { + key(preview.id) { preview.content(context) } + } + }, + ) + HollowIdeDiagnosticsBadge("${file.id}-preview", view.problems.map { it.diagnostic }) { showProblems(file) } + } } private fun fileView(itemId: String): HollowIdeFileView? = @@ -1300,10 +1343,11 @@ object HollowIdeOverlay { /** Only text files get a preview: it reads and writes the text, whatever type the file has. */ private fun fileView(file: HollowIdeOpenFile): HollowIdeFileView? { + contributions.revision if (file.textOrNull == null) return null fileViews[file.path]?.let { return it } - val preview = previews.find(file.path) ?: return null - return HollowIdeFileView(preview).also { fileViews[file.path] = it } + val found = previews.find(file.path).takeIf { it.isNotEmpty() } ?: return null + return HollowIdeFileView(found).also { fileViews[file.path] = it } } @Composable @@ -1464,6 +1508,11 @@ object HollowIdeOverlay { } == true HollowKeys.DEFINITION.matches(key, modifiers) -> goToDefinition() + HollowKeys.RENAME.matches(key, modifiers) -> focusedEditorFile()?.let { file -> + val caret = editorStates[file.path]?.caret ?: return@let false + codeRename.start(file, caret, surface.runtime.mouseX, surface.runtime.mouseY) + } == true + HollowKeys.KEYMAP.matches(key, modifiers) -> true.also { keymapVisible = true } else -> false } @@ -1548,9 +1597,15 @@ object HollowIdeOverlay { } /** Writes [next] into the editor as one undoable edit and keeps the model and tab in step. */ - private fun applyEditorText(file: HollowIdeOpenFile, next: String, caret: Int) { + private fun applyEditorText( + file: HollowIdeOpenFile, + next: String, + caret: Int, + label: UndoLabel? = null, + mergeKey: String? = null, + ) { val editor = editorState(file) - if (!editor.applyEdit(next, listOf(UiTextCaret(caret.coerceIn(0, next.length))))) return + if (!editor.applyEdit(next, listOf(UiTextCaret(caret.coerceIn(0, next.length))), label, mergeKey)) return model.updateText(file.path, editor.text) dock.updateItem(file.dockItem()) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt index 63e306185..9f44e2aaa 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt @@ -5,6 +5,7 @@ import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.TextFieldState import ru.hollowhorizon.hollowengine.client.utils.IconHelper import ru.hollowhorizon.hollowengine.client.utils.lang @@ -13,10 +14,11 @@ internal fun HollowIdeProjectContextMenu( menu: ProjectContextMenu?, onCreateFile: (String) -> Unit, onCreateFolder: (String) -> Unit, - onCreateScript: (String, ScriptTemplate) -> Unit, + onCreateScript: (String, HollowIdeScriptTemplate) -> Unit, onCreateSoundEvents: (String) -> Unit, onRename: (String) -> Unit, onCopy: (String) -> Unit, + onCopyPath: (String) -> Unit, onCut: (String) -> Unit, onPaste: (String) -> Unit, onShowInExplorer: (String) -> Unit, @@ -36,7 +38,11 @@ internal fun HollowIdeProjectContextMenu( onDismiss = onDismiss, ) { ProjectMenuItem("New File", "Alt+Insert", ProjectMenuIcons.NEW_FILE, closeScripts) { onCreateFile(menu.path) } - ProjectMenuItem("New Folder", "Alt+Shift+Insert", ProjectMenuIcons.NEW_FOLDER, closeScripts) { onCreateFolder(menu.path) } + ProjectMenuItem("New Folder", "Alt+Shift+Insert", ProjectMenuIcons.NEW_FOLDER, closeScripts) { + onCreateFolder( + menu.path + ) + } if (menu.canCreateScripts) { ProjectMenuItem( label = "New Script", @@ -47,14 +53,50 @@ internal fun HollowIdeProjectContextMenu( ) { scriptsOpen = !scriptsOpen } } if (menu.canCreateSoundEvents) { - ProjectMenuItem("New Sound Events", "", IconHelper.forFile("sounds.ogg").toString(), closeScripts) { onCreateSoundEvents(menu.path) } + ProjectMenuItem( + "New Sound Events", + "", + IconHelper.forFile("sounds.ogg").toString(), + closeScripts + ) { onCreateSoundEvents(menu.path) } + } + ProjectMenuItem( + "Rename", + HollowKeys.RENAME.shortcut.orEmpty(), + ProjectMenuIcons.RENAME, + closeScripts + ) { onRename(menu.path) } + ProjectMenuItem( + "Copy", + HollowKeys.COPY.shortcut.orEmpty(), + ProjectMenuIcons.COPY, + closeScripts + ) { onCopy(menu.path) } + ProjectMenuItem( + "hollowengine.gui.ide.project.copy_path".lang, + "", + ProjectMenuIcons.COPY, + closeScripts, + id = "project-copy-path" + ) { onCopyPath(menu.path) } + ProjectMenuItem( + "Cut", + HollowKeys.CUT.shortcut.orEmpty(), + ProjectMenuIcons.CUT, + closeScripts + ) { onCut(menu.path) } + ProjectMenuItem("Paste", HollowKeys.PASTE.shortcut.orEmpty(), ProjectMenuIcons.PASTE, closeScripts) { + onPaste( + menu.path + ) } - ProjectMenuItem("Rename", HollowKeys.RENAME.shortcut.orEmpty(), ProjectMenuIcons.RENAME, closeScripts) { onRename(menu.path) } - ProjectMenuItem("Copy", HollowKeys.COPY.shortcut.orEmpty(), ProjectMenuIcons.COPY, closeScripts) { onCopy(menu.path) } - ProjectMenuItem("Cut", HollowKeys.CUT.shortcut.orEmpty(), ProjectMenuIcons.CUT, closeScripts) { onCut(menu.path) } - ProjectMenuItem("Paste", HollowKeys.PASTE.shortcut.orEmpty(), ProjectMenuIcons.PASTE, closeScripts) { onPaste(menu.path) } ProjectMenuItem("Show in Explorer", "", ProjectMenuIcons.REVEAL, closeScripts) { onShowInExplorer(menu.path) } - ProjectMenuItem("Delete", HollowKeys.DELETE.shortcut.orEmpty(), ProjectMenuIcons.DELETE, closeScripts) { onDelete(menu.path) } + ProjectMenuItem( + "Delete", + HollowKeys.DELETE.shortcut.orEmpty(), + ProjectMenuIcons.DELETE, + closeScripts + ) { onDelete(menu.path) } contributedActions(menu).forEach { action -> ProjectMenuItem(action.label, "", action.icon, closeScripts) { onDismiss() @@ -73,8 +115,13 @@ internal fun HollowIdeProjectContextMenu( tags = listOf("dropdown-popup", "project-context-menu"), onDismiss = onDismiss, ) { - ScriptTemplate.entries.forEach { template -> - ProjectMenuItem(template.label, template.extension, template.icon) { onCreateScript(menu.path, template) } + scriptTemplates().forEach { template -> + ProjectMenuItem(template.label, template.extension, template.icon) { + onCreateScript( + menu.path, + template + ) + } } } } @@ -98,6 +145,14 @@ internal fun HollowIdeProjectNameDialog( onCancel: () -> Unit, ) { if (dialog == null) return + val fieldState = remember(dialog.action, dialog.path) { + TextFieldState(initialText = dialog.name).apply { + if (dialog.action == ProjectNameAction.Rename) { + val extensionStart = dialog.name.lastIndexOf('.').takeIf { !dialog.isDirectory && it > 0 } + setSelection(0, extensionStart ?: dialog.name.length) + } + } + } Popup( anchorBounds = UiRect(dialog.x, dialog.y, 0f, 0f), alignment = UiPopupAlignment.Cursor, @@ -105,10 +160,13 @@ internal fun HollowIdeProjectNameDialog( tags = listOf("dropdown-popup", "project-name-dialog"), modal = true, ) { + val focus = LocalUiFocusRequester.current + LaunchedEffect(Unit) { focus.request(ProjectNameDialogInputId) } Text(dialog.title, tags = listOf("project-name-dialog-title")) TextField( value = dialog.name, onChange = onNameChange, + state = fieldState, id = ProjectNameDialogInputId, tags = listOf("project-name-dialog-input"), modifier = Modifier.size(180.px, 24.px).onKeyInput { input -> @@ -139,23 +197,24 @@ private fun ProjectMenuItem( icon: String? = null, onEnter: () -> Unit = {}, onPlaced: ((UiRect) -> Unit)? = null, + id: String? = null, action: () -> Unit, ) { Row( + id = id, tags = listOf("dropdown-item", "project-context-menu-item"), - modifier = Modifier.input(hoverable = true, clickable = true) - .cursor(UiCursorShape.HAND) - .alignItems(vertical = UiAlign.CENTER) - .onEnter { onEnter() } - .let { if (onPlaced != null) it.onPlaced(onPlaced) else it } - .onClick { event -> + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .alignItems(vertical = UiAlign.CENTER).onEnter { onEnter() } + .let { if (onPlaced != null) it.onPlaced(onPlaced) else it }.onClick { event -> action() event.consume() - } - ) { + }) { if (icon != null) Image(icon, tags = listOf("dropdown-item-icon", "project-context-menu-icon")) Text(label, tags = listOf("dropdown-item-label", "project-context-menu-label")) - if (shortcut.isNotBlank()) Text(shortcut, tags = listOf("dropdown-item-shortcut", "project-context-menu-shortcut")) + if (shortcut.isNotBlank()) Text( + shortcut, + tags = listOf("dropdown-item-shortcut", "project-context-menu-shortcut") + ) } } @@ -173,7 +232,8 @@ internal data class ProjectNameDialog( val name: String, val x: Float, val y: Float, - val template: ScriptTemplate? = null, + val template: HollowIdeScriptTemplate? = null, + val isDirectory: Boolean = false, ) { val title: String get() = when (action) { @@ -184,9 +244,7 @@ internal data class ProjectNameDialog( } internal enum class ProjectNameAction { - CreateFile, - CreateFolder, - Rename, + CreateFile, CreateFolder, Rename, } internal fun HollowIdeFileOperationResult.statusText(): String { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt index 5ec11d1e8..7c8052757 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectController.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import net.minecraft.client.Minecraft import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.UiEvent @@ -78,7 +79,7 @@ internal class HollowIdeProjectController( fun openCreateFileDialog(path: String) = openNameDialog(ProjectNameAction.CreateFile, path) - fun openCreateScriptDialog(path: String, template: ScriptTemplate) = + fun openCreateScriptDialog(path: String, template: HollowIdeScriptTemplate) = openNameDialog(ProjectNameAction.CreateFile, path, template) fun openCreateFolderDialog(path: String) = openNameDialog(ProjectNameAction.CreateFolder, path) @@ -102,9 +103,13 @@ internal class HollowIdeProjectController( if (result == HollowIdeFileOperationResult.Success) nameDialog = null } - private fun createScript(dialog: ProjectNameDialog, template: ScriptTemplate): HollowIdeFileOperationResult { + private fun createScript( + dialog: ProjectNameDialog, + template: HollowIdeScriptTemplate, + ): HollowIdeFileOperationResult { val name = template.fileName(dialog.name) - val directory = if (dialog.path.fromReadablePath().isDirectory) dialog.path else dialog.path.substringBeforeLast('/', "") + val directory = + if (dialog.path.fromReadablePath().isDirectory) dialog.path else dialog.path.substringBeforeLast('/', "") val path = if (directory.isBlank()) name else "$directory/$name" val result = model.createFile(dialog.path, name, template.render(path)) if (result == HollowIdeFileOperationResult.Success) { @@ -123,6 +128,11 @@ internal class HollowIdeProjectController( contextMenu = null } + fun copyPath(path: String = model.selectedTreePath) { + Minecraft.getInstance().keyboardHandler.clipboard = path + contextMenu = null + } + fun pasteInto(path: String = model.selectedTreePath) { contextMenu = null setStatus("Pasting...") @@ -173,10 +183,12 @@ internal class HollowIdeProjectController( applyNameDialog() true } + GLFW.GLFW_KEY_ESCAPE -> { cancelNameDialog() true } + else -> false } } @@ -188,30 +200,37 @@ internal class HollowIdeProjectController( copy(cut = false) true } + ProjectShortcut.Cut -> { copy(cut = true) true } + ProjectShortcut.Paste -> { pasteInto() true } + ProjectShortcut.Delete -> { delete() true } + ProjectShortcut.Rename -> { openRenameDialog(model.selectedTreePath) true } + ProjectShortcut.CreateFolder -> { openCreateFolderDialog(model.selectedTreePath) true } + ProjectShortcut.CreateFile -> { openCreateFileDialog(model.selectedTreePath) true } + null -> false } } @@ -250,7 +269,7 @@ internal class HollowIdeProjectController( if (result != HollowIdeFileOperationResult.Success) HollowNotifications.error(result.statusText()) } - private fun openNameDialog(action: ProjectNameAction, path: String, template: ScriptTemplate? = null) { + private fun openNameDialog(action: ProjectNameAction, path: String, template: HollowIdeScriptTemplate? = null) { val defaultName = when (action) { ProjectNameAction.Rename -> path.substringAfterLast('/') ProjectNameAction.CreateFile -> template?.suggestedFileName ?: "new_file.kts" @@ -265,6 +284,7 @@ internal class HollowIdeProjectController( x = menu?.x ?: pointerX(), y = menu?.y ?: pointerY(), template = template, + isDirectory = path.fromReadablePath().isDirectory, ) } @@ -287,13 +307,7 @@ internal class HollowIdeProjectController( } internal enum class ProjectShortcut { - Copy, - Cut, - Paste, - Delete, - Rename, - CreateFolder, - CreateFile, + Copy, Cut, Paste, Delete, Rename, CreateFolder, CreateFile, } internal fun resolveProjectShortcut( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectDialogs.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectDialogs.kt index aceaf65e1..f43185312 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectDialogs.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectDialogs.kt @@ -40,6 +40,8 @@ internal fun HollowIdeProjectDialogs(packaging: HollowIdeProjectPackaging) { @Composable private fun SettingsDialog(packaging: HollowIdeProjectPackaging, form: ProjectSettingsForm) { ProjectDialog("project-settings", "$LANG.settings.title".lang, packaging::cancelSettings) { + val focus = LocalUiFocusRequester.current + LaunchedEffect(Unit) { focus.request("project-settings-id") } val viewport = LocalUiViewport.current Column( id = "project-settings-fields", @@ -190,6 +192,8 @@ private fun ExportDialog(packaging: HollowIdeProjectPackaging, form: ProjectExpo val blocked = properties.id == DEFAULT_SANDBOX_NAMESPACE Paragraph("${properties.displayName} (${properties.id})") Field("$LANG.field.version".lang, form.version, "project-export-version") { form.version = it } + val focus = LocalUiFocusRequester.current + LaunchedEffect(Unit) { focus.request("project-export-version") } Row( tags = listOf("project-dialog-checkbox-row"), modifier = Modifier.alignItems(vertical = UiAlign.CENTER), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt index 348ec0864..846b4d496 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeScriptTemplates.kt @@ -5,25 +5,25 @@ import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry /** - * The kinds of script the project tree can create, each with a starter file taken from - * `internal/templates`. A template may use `{{name}}` (the file name without its extension), `{{id}}` (the - * same, made a valid resource path), `{{constant}}` (the id as a Kotlin constant name), `{{namespace}}` (the - * sandbox namespace) and `{{path}}` (new file's project path). + * A kind of script the project tree can create, with its starter text. The text may use `{{name}}` (the + * file name without its extension), `{{id}}` (the same, made a valid resource path), `{{constant}}` (the id + * as a Kotlin constant name), `{{namespace}}` (the sandbox namespace) and `{{path}}` (new file's project path). + * + * @param label shown in the "New script" menu and in the title of the name dialog. + * @param extension appended to the typed name when it lacks it, such as `.node.kts`. + * @param text reads the starter text; called each time a file is created. */ -internal enum class ScriptTemplate( +class HollowIdeScriptTemplate( + val id: String, val label: String, val extension: String, private val defaultName: String, - private val resource: String, + private val text: () -> String, ) { - Plain("Script", ".kts", "script", "plain.kts"), - ServerReload("Server Reload Script", ".reload.kts", "server", "server.reload.kts"), - ClientReload("Client Reload Script", ".reload.kts", "client", "client.reload.kts"), - Startup("Startup Script", ".startup.kts", "ruby", "startup.kts"), - Node("Node Script", ".node.kts", "node", "node.kts"), - Ui("UI Script", ".ui.kts", "menu", "ui.kts"), - Mixin("Mixin Script", ".mixin.kts", "mixins", "mixin.kts"), - Dialogue("Dialogue", ".story", "dialogue", "dialogue.story"); + init { + require(id.isNotBlank()) { "Script template ID cannot be blank" } + require(extension.startsWith('.')) { "Script template extension must start with a dot: $extension" } + } val suggestedFileName: String get() = defaultName + extension @@ -42,11 +42,7 @@ internal enum class ScriptTemplate( val name = path.substringAfterLast('/').removeSuffix(extension) val id = name.lowercase().replace(INVALID_ID_CHARS, "_").ifEmpty { "script" } val constant = id.uppercase().replace(INVALID_CONSTANT_CHARS, "_").let { if (it.first().isDigit()) "_$it" else it } - val text = ScriptTemplate::class.java.classLoader - .getResourceAsStream("internal/templates/$resource.template") - ?.use { it.readBytes().toString(Charsets.UTF_8) } - .orEmpty() - return text + return text() .replace("{{name}}", name) .replace("{{id}}", id) .replace("{{constant}}", constant) @@ -54,13 +50,45 @@ internal enum class ScriptTemplate( .replace("{{path}}", path) } - private companion object { - const val KOTLIN_SCRIPT = ".kts" - val INVALID_ID_CHARS = Regex("[^a-z0-9_.-]") - val INVALID_CONSTANT_CHARS = Regex("[^A-Z0-9_]") + companion object { + private const val KOTLIN_SCRIPT = ".kts" + private val INVALID_ID_CHARS = Regex("[^a-z0-9_.-]") + private val INVALID_CONSTANT_CHARS = Regex("[^A-Z0-9_]") + + /** A template whose text is the `internal/templates/.template` file of [owner]'s jar. */ + fun fromResource( + id: String, + label: String, + extension: String, + defaultName: String, + resource: String, + owner: Class<*>, + ) = HollowIdeScriptTemplate(id, label, extension, defaultName) { + owner.classLoader.getResourceAsStream("internal/templates/$resource.template") + ?.use { it.readBytes().toString(Charsets.UTF_8) } + .orEmpty() + } } } +private fun builtIn(id: String, label: String, extension: String, defaultName: String, resource: String) = + HollowIdeScriptTemplate.fromResource(id, label, extension, defaultName, resource, HollowIdeScriptTemplate::class.java) + +/** The engine's own templates; addons add theirs through [HollowIdeExtensionPoints.SCRIPT_TEMPLATES]. */ +private val BuiltInScriptTemplates = listOf( + builtIn("plain", "Script", ".kts", "script", "plain.kts"), + builtIn("server_reload", "Server Reload Script", ".reload.kts", "server", "server.reload.kts"), + builtIn("client_reload", "Client Reload Script", ".reload.kts", "client", "client.reload.kts"), + builtIn("startup", "Startup Script", ".startup.kts", "ruby", "startup.kts"), + builtIn("node", "Node Script", ".node.kts", "node", "node.kts"), + builtIn("ui", "UI Script", ".ui.kts", "menu", "ui.kts"), + builtIn("mixin", "Mixin Script", ".mixin.kts", "mixins", "mixin.kts"), +) + +/** What the "New script" menu offers: the engine's templates, then those of loaded addons. */ +internal fun scriptTemplates(): List = + BuiltInScriptTemplates + HollowIdeExtensionPoints.SCRIPT_TEMPLATES.extensions + /** Scripts are only picked up from the sandbox's `scripts` folder, so that is the only place to offer them. */ internal fun isInsideScripts(path: String): Boolean = path == SandboxScriptSource.SCRIPTS_DIRECTORY || path.startsWith(SandboxScriptSource.SCRIPTS_DIRECTORY + "/") diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeSearchOverlay.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeSearchOverlay.kt index 6105cfb62..0e4cad1c2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeSearchOverlay.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeSearchOverlay.kt @@ -286,9 +286,8 @@ internal fun HollowIdeSearchDialog( }, ) } - LaunchedEffect(Unit) { - Minecraft.getInstance().execute { HollowIdeOverlay.focusSurface(SearchOverlayInputId) } - } + val focus = LocalUiFocusRequester.current + LaunchedEffect(Unit) { focus.request(SearchOverlayInputId) } if (controller.results.isNotEmpty()) { Column(tags = listOf("ide-search-results"), modifier = Modifier.scrollable()) { controller.results.forEachIndexed { index, result -> diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt new file mode 100644 index 000000000..708c8a699 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeTextFileSync.kt @@ -0,0 +1,38 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import java.nio.file.Files +import java.nio.file.Path + +/** Coordinates disk access by autosave and the client thread against the last accepted contents. */ +internal class HollowIdeTextFileSync( + private val path: Path, + initialContent: ByteArray, +) { + private var content = initialContent + private var externalRevision = 0L + + @Synchronized + fun revision(): Long = externalRevision + + /** External changes win even when the editor still has a pending local edit. */ + @Synchronized + fun reloadIfChanged(reload: (ByteArray) -> Unit): Boolean { + val current = Files.readAllBytes(path) + if (content.contentEquals(current)) return false + reload(current) + content = current + externalRevision++ + return true + } + + /** A delayed save cannot write through a reload or replace an unobserved external edit. */ + @Synchronized + fun save(bytes: ByteArray, revision: Long, checkActive: () -> Unit = {}): Boolean { + checkActive() + if (revision != externalRevision || !content.contentEquals(Files.readAllBytes(path))) return false + checkActive() + writeIdeFile(path, bytes) + content = bytes + return true + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt index 00b9bf323..6e9790bf6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeWorldMenu.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.setValue import net.minecraft.client.Minecraft import net.minecraft.world.Difficulty import net.minecraft.world.level.GameType -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.TimeOfDayValueFormatter +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimeOfDayValueFormatter import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownSlider diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt index 823fe766b..5b54a489b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletions.kt @@ -12,8 +12,22 @@ import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionCloseness import ru.hollowhorizon.hollowengine.common.scripting.ide.isKotlinCodeAt -/** Reads player and world, so it runs on client thread. */ -internal fun ideContextCompletions(path: String, context: UiCompletionContext): List { +/** What the snippets evaluate to, as scripts see these classes: they compile against Mojang names. */ +internal const val PositionSnippetType = "net.minecraft.world.phys.Vec3" +internal const val ItemSnippetType = "net.minecraft.world.item.ItemStack" +internal val ContextSnippetTypes = listOf(PositionSnippetType, ItemSnippetType) + +/** + * Snippets built from where the player stands and what they hold, offered where an expression of their type + * may go. [acceptedTypes] is what the analyzer said fits at the caret, out of [ContextSnippetTypes]; null means + * no expression starts there. Reads player and world, so it runs on client thread. + */ +internal fun ideContextCompletions( + path: String, + context: UiCompletionContext, + acceptedTypes: List?, +): List { + if (acceptedTypes.isNullOrEmpty()) return emptyList() if (!path.isKotlinSource() || !context.text.isKotlinCodeAt(context.caret)) return emptyList() val word = completionWordRange(context.text, context.caret) val suffix = context.text.substring(word.last).trimStart() @@ -32,22 +46,26 @@ internal fun ideContextCompletions(path: String, context: UiCompletionContext): patch?.let { itemSpecification(stack.itemHolder.unwrapKey().orElseThrow().location().toString(), it) } } } else null - return contextSnippetCompletions(position, specification, stack.count) + return contextSnippetCompletions(position, specification, stack.count, acceptedTypes) } -internal fun contextSnippetCompletions(position: Vec3, heldItem: String?, count: Int): List = - buildList { - add( - contextSnippet( - "pos", "pos(${position.x}, ${position.y}, ${position.z})", "player_position" - ) +internal fun contextSnippetCompletions( + position: Vec3, + heldItem: String?, + count: Int, + acceptedTypes: List = ContextSnippetTypes, +): List = buildList { + if (PositionSnippetType in acceptedTypes) add( + contextSnippet( + "pos", "pos(${position.x}, ${position.y}, ${position.z})", "player_position" ) - if (heldItem != null) add( - contextSnippet( - "item", "item(\"${heldItem.escapeForRegularKotlinString()}\", $count)", "held_item" - ) + ) + if (heldItem != null && ItemSnippetType in acceptedTypes) add( + contextSnippet( + "item", "item(\"${heldItem.escapeForRegularKotlinString()}\", $count)", "held_item" ) - } + ) +} private fun contextSnippet(name: String, insertion: String, label: String) = UiTextCompletion( label = insertion, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeInspectorHost.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeInspectorHost.kt index ebe95b728..64710be58 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeInspectorHost.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeInspectorHost.kt @@ -4,9 +4,9 @@ import androidx.compose.runtime.* import ru.hollowhorizon.hollowengine.client.ui.inspector.* /** - * What the inspector window of the IDE offers the fields it shows: the assets of the loaded packs, - * and a tree to pick one from. A target that needs more (the entity editor lists the scripts of the - * server) provides a host of its own inside its content. + * What the IDE offers the fields of its inspector and previews: the assets of the loaded packs, and a tree to pick one + * from, which the IDE opens over everything. A target that needs more (the entity editor lists the scripts of the server) + * provides a host of its own inside its content. */ internal object IdeInspectorHost : InspectorHost { var pending by mutableStateOf(null) @@ -27,6 +27,5 @@ internal fun IdeInspectorDock() { empty = InspectorLang.nothingSelected, keepScroll = true, ) - IdeInspectorHost.pending?.let { request -> AssetPickerDialog(request) { IdeInspectorHost.pending = null } } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/KotlinCodeSyntax.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/KotlinCodeSyntax.kt new file mode 100644 index 000000000..b2a8a86f1 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/KotlinCodeSyntax.kt @@ -0,0 +1,23 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeSyntax +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinLexerMode +import ru.hollowhorizon.hollowengine.common.scripting.ide.kotlinLexerModeAt + +/** Kotlin for code editing, read by the lexer the IDE tells strings and comments apart with. */ +object KotlinCodeSyntax : UiCodeSyntax { + override fun scan( + text: String, + limit: Int, + onCode: ((Int) -> Unit)?, + onLineStart: ((offset: Int, inString: Boolean) -> Unit)?, + onLiteralEnd: ((Int) -> Unit)?, + ): Boolean { + val lines = onLineStart?.let { report -> + { offset: Int, mode: KotlinLexerMode -> + report(offset, mode == KotlinLexerMode.RAW_STRING || mode == KotlinLexerMode.REGULAR_STRING) + } + } + return text.kotlinLexerModeAt(limit, onCode, lines, onLiteralEnd) == KotlinLexerMode.CODE + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/KotlinFragmentInsight.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/KotlinFragmentInsight.kt new file mode 100644 index 000000000..7d72fb4ea --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/KotlinFragmentInsight.kt @@ -0,0 +1,76 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCompletionContext +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCompletionContributor +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiInlineStyle +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiSyntaxHighlighter +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextHighlight +import ru.hollowhorizon.hollowengine.client.ui.widgets.withColor +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinPropertyTypes +import ru.hollowhorizon.hollowengine.common.scripting.ide.KotlinToken + +/** What surrounds a fragment of Kotlin in the file it belongs to. */ +data class KotlinFragmentContext(val before: String, val after: String) + +/** + * What analysis made of a whole file. + * + * @param properties what the properties it was asked about turned out to be. + */ +class KotlinFragmentAnalysis( + val highlights: List, + val diagnostics: List, + val properties: KotlinPropertyTypes, +) + +/** Colors text by what a parser made of it, which any number of fields can do at once. */ +fun kotlinTokenHighlighter(tokens: (String) -> List) = UiSyntaxHighlighter { text -> + tokens(text).map { UiTextHighlight(it.start, it.end, UiInlineStyle().withColor(it.type.toUiColor())) } +} + +/** + * Analyzes a file for fields holding Kotlin outside of an editor tab, keeping one analysis at a time. + * + * @param path the name analysis knows the text by; its extension picks the language. + * @param typedProperties the start of the names of the properties to tell the types of. + * @param placeProperties the start of the names of the properties to tell whether those fit. + */ +class KotlinFragmentInsight(path: String, typedProperties: String? = null, placeProperties: String? = null) : AutoCloseable { + private var revision by mutableLongStateOf(0L) + private val session: HollowIdeEditorSession = HollowIdeEditorSession(path, typedProperties, placeProperties) { revision = session.revision } + + /** Moves on with analysis, for telling a field to read it again. */ + val version: Long get() = revision + + /** What analysis made of the file [text], as soon as it has it. */ + fun analyzed(text: String): KotlinFragmentAnalysis? { + revision + return session.exactAnalysis(text) + } + + /** + * Completes a field holding a part of the file [context] gives. + * + * @param onImport adds a name to the imports, which a field that holds a part of the file has no place for. + */ + fun completions(context: () -> KotlinFragmentContext, onImport: (String) -> Unit) = object : UiCompletionContributor { + override val triggerCharacters: String get() = "." + + override fun complete(field: UiCompletionContext): List { + val around = context() + val text = around.before + field.text + around.after + return session.completions.complete(field.copy(text = text, caret = around.before.length + field.caret)).map { item -> + val name = item.importFqName ?: return@map item + item.copy(importFqName = null, onAccept = { onImport(name) }) + } + } + } + + /** Ends analysis; what was found stays readable, and nothing new gets found. */ + override fun close() = session.close() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt index e375d3968..967d14d81 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment import ru.hollowhorizon.hollowengine.common.scripting.ide.* -import ru.hollowhorizon.hollowengine.common.scripting.ide.story.StoryScriptingAnalyzer import ru.hollowhorizon.hollowengine.common.scripting.ide.ui.HssScriptingAnalyzer interface EditorLanguageService { @@ -15,7 +14,6 @@ fun EditorLanguageService(extension: String): EditorLanguageService { "java" -> JavaEditorLanguageService "json" -> JsonEditorLanguageService "hss" -> HssEditorLanguageService - "story" -> StoryEditorLanguageService else -> error("Unsupported language: $extension") } } @@ -45,11 +43,6 @@ object HssEditorLanguageService : EditorLanguageService { get() = HssScriptingAnalyzer } -object StoryEditorLanguageService : EditorLanguageService { - override val analyzer: ScriptingAnalyzer - get() = StoryScriptingAnalyzer -} - /** * A language an addon contributes to the editor through `registerIdeLanguage`. It takes precedence * over the built-in languages for the paths it [matches]. diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocument.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocument.kt index 21611318f..44e9fd861 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocument.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocument.kt @@ -3,7 +3,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.NbtIo import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.history.SnapshotStep import ru.hollowhorizon.hollowengine.client.history.UndoHistory @@ -12,20 +12,27 @@ import ru.hollowhorizon.hollowengine.client.history.UndoOwner import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeFileDocument import ru.hollowhorizon.hollowengine.common.models.Animator import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat -import ru.hollowhorizon.hollowengine.common.utils.nbt.loadAsNBT import ru.hollowhorizon.hollowengine.common.utils.nbt.save import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.DataInputStream /** - * An open `.animator` file: one [Animator] the editor rewrites whole. + * An open `.animator` file: one [Animator] the editor rewrites whole. A file that could not be read stays as it is + * on the disk and cannot be edited. */ class HollowIdeAnimatorDocument(bytes: ByteArray) : HollowIdeFileDocument, UndoOwner { - override val readOnly: Boolean = false + var animator by mutableStateOf(Animator()) + private set - var animator by mutableStateOf(decode(bytes)) + /** What went wrong reading the file, or null when it read fine. */ + var error by mutableStateOf(null) private set + private var original: ByteArray = bytes + + override val readOnly: Boolean get() = error != null + var isModified by mutableStateOf(false) private set @@ -36,6 +43,10 @@ class HollowIdeAnimatorDocument(bytes: ByteArray) : HollowIdeFileDocument, UndoO /** What the editor keeps while the file stays open, such as where the view was, across tab switches. */ private var editorState: Any? = null + init { + load(bytes) + } + @Suppress("UNCHECKED_CAST") fun editorState(create: () -> T): T = (editorState as? T) ?: create().also { editorState = it } @@ -46,6 +57,7 @@ class HollowIdeAnimatorDocument(bytes: ByteArray) : HollowIdeFileDocument, UndoO * sharing [mergeKey] in quick succession, such as the frames of a drag, undo together. */ fun edit(mergeKey: String? = null, label: UndoLabel? = null, change: (Animator) -> Animator) { + if (readOnly) return val next = change(animator) if (next == animator) return @@ -60,14 +72,17 @@ class HollowIdeAnimatorDocument(bytes: ByteArray) : HollowIdeFileDocument, UndoO } override fun encode(): ByteArray { + if (readOnly) return original val tag = NBTFormat.serialize(Animator.serializer(), animator) return ByteArrayOutputStream().also { tag.save(it) }.toByteArray() } override fun reload(bytes: ByteArray) { - if (decode(bytes) == animator) return + val before = animator + val broken = readOnly + load(bytes) + if (!broken && !readOnly && animator == before) return history.clear() - animator = decode(bytes) isModified = false revision++ } @@ -76,16 +91,23 @@ class HollowIdeAnimatorDocument(bytes: ByteArray) : HollowIdeFileDocument, UndoO isModified = false } + private fun load(bytes: ByteArray) { + original = bytes + try { + animator = if (bytes.isEmpty()) Animator() else read(bytes) + error = null + } catch (e: Exception) { + animator = Animator() + error = e.message ?: e::class.simpleName + HollowEngine.LOGGER.warn("Could not read animator: {}", error) + } + } + private companion object { - fun decode(bytes: ByteArray): Animator { - if (bytes.isEmpty()) return Animator() - return try { - val tag = ByteArrayInputStream(bytes).use { it.loadAsNBT() } - NBTFormat.deserialize(Animator.serializer(), tag as? CompoundTag ?: return Animator()) - } catch (e: Exception) { - HollowEngine.LOGGER.warn("Could not read animator: {}", e.message) - Animator() - } + /** Reads with [NbtIo] itself, since `loadAsNBT` takes a broken file for an empty tag. */ + fun read(bytes: ByteArray): Animator { + val tag = DataInputStream(ByteArrayInputStream(bytes)).use { NbtIo.read(it) } + return NBTFormat.deserialize(Animator.serializer(), tag) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeShaderGraphDocument.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeShaderGraphDocument.kt index b3357dacd..9ed4f136f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeShaderGraphDocument.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeShaderGraphDocument.kt @@ -61,6 +61,9 @@ class HollowIdeShaderGraphDocument(bytes: ByteArray) : HollowIdeFileDocument, Un fun endGesture() = history.commit() + /** Ends the gesture with the graph as it was when it began. */ + fun cancelGesture() = history.rollback() + private fun apply(next: ShaderGraph) { graph = next isModified = true diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorEditorModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorEditorModel.kt index b4a33a983..753d3cfaf 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorEditorModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorEditorModel.kt @@ -14,8 +14,8 @@ sealed interface AnimatorSelection { data class State(val layerId: String, val stateId: String) : AnimatorSelection - /** Transitions have no id of their own, so they are addressed by position in their layer. */ - data class Transition(val layerId: String, val index: Int) : AnimatorSelection + /** Transitions have no id of their own, so they are addressed by the two states they join. */ + data class Transition(val layerId: String, val from: String, val to: String) : AnimatorSelection } object AnimatorColors { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorGraphCanvas.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorGraphCanvas.kt index 69bc7a0df..3d7df99cf 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorGraphCanvas.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorGraphCanvas.kt @@ -25,7 +25,7 @@ private const val ResetIcon = "hollowengine:textures/gui/icons/reload.svg" /** Where a right click landed: on a state, on a transition, or on the empty canvas. */ private sealed interface CanvasMenuTarget { data class State(val stateId: String) : CanvasMenuTarget - data class Transition(val index: Int) : CanvasMenuTarget + data class Transition(val link: AnimatorSelection.Transition) : CanvasMenuTarget data object Empty : CanvasMenuTarget } @@ -61,6 +61,9 @@ internal fun AnimatorGraphCanvas( val onCanvas = boxes.mapValues { (_, box) -> box.toCanvas(view) } val transitionCurves = controller.transitionCurves(onCanvas, view.zoom) + fun keyOf(transition: AnimationControllerTransitionSpec) = + AnimatorSelection.Transition(layerId, transition.from, transition.to) + /** Ends a link being drawn: a transition to the state it was dropped on, or nothing. */ fun finishLink() { val drawn = link ?: return @@ -76,8 +79,9 @@ internal fun AnimatorGraphCanvas( id = AnimatorCanvasId, view = view, modifier = modifier, - edges = transitionCurves.map { (index, curve) -> - GraphEdge(index, curve, arrow = true, selected = selection == AnimatorSelection.Transition(layerId, index)) + edges = transitionCurves.map { (transition, curve) -> + val key = keyOf(transition) + GraphEdge(key, curve, arrow = true, selected = selection == key) }, link = pending?.let { from -> val box = onCanvas[from.from] ?: return@let null @@ -96,9 +100,9 @@ internal fun AnimatorGraphCanvas( }, onBackgroundClick = { onSelect(AnimatorSelection.None) }, onRelease = ::finishLink, - onEdgeClick = { index -> onSelect(AnimatorSelection.Transition(layerId, index as Int)) }, + onEdgeClick = { edge -> onSelect(edge as AnimatorSelection.Transition) }, onContextMenu = { pointer, edge -> - val target = (edge as? Int)?.let(CanvasMenuTarget::Transition) ?: CanvasMenuTarget.Empty + val target = (edge as? AnimatorSelection.Transition)?.let(CanvasMenuTarget::Transition) ?: CanvasMenuTarget.Empty menu = CanvasMenu(target, pointer.screenX, pointer.screenY, GraphPoint(pointer.graphX, pointer.graphY)) }, onKey = { input -> @@ -112,18 +116,17 @@ internal fun AnimatorGraphCanvas( } }, ) { - transitionCurves.forEach { (index, curve) -> + transitionCurves.forEach { (transition, curve) -> // The two links of a pair run side by side in opposite directions, so a label a third of // the way along each sits near its own start and the two never land on each other. - val transition = controller.transitions[index] val paired = controller.transitions.any { it.from == transition.to && it.to == transition.from } val label = curve.pointAt(if (paired) 0.3f else 0.5f) TransitionLabel( transition = transition, - selected = selection == AnimatorSelection.Transition(layerId, index), + selected = selection == keyOf(transition), x = view.toGraphX(label[0]), y = view.toGraphY(label[1]), - onSelect = { onSelect(AnimatorSelection.Transition(layerId, index)) }, + onSelect = { onSelect(keyOf(transition)) }, ) } @@ -254,7 +257,7 @@ private fun handleKey( if (selection.stateId == ANY_STATE) it.withoutAnyState(layerId) else it.withoutState(layerId, selection.stateId) } - is AnimatorSelection.Transition -> document.edit { it.withoutTransitionAt(layerId, selection.index) } + is AnimatorSelection.Transition -> document.edit { it.withoutTransition(layerId, selection.from, selection.to) } else -> return false } onSelect(AnimatorSelection.Layer(layerId)) @@ -294,7 +297,7 @@ private fun CanvasContextMenu( items = when (val target = menu.target) { is CanvasMenuTarget.State -> stateMenu(document, layerId, target.stateId, onSelect) is CanvasMenuTarget.Transition -> listOf(UiDropdownItem(animatorText("delete_transition")) { - document.edit { it.withoutTransitionAt(layerId, target.index) } + document.edit { it.withoutTransition(layerId, target.link.from, target.link.to) } onSelect(AnimatorSelection.Layer(layerId)) }) @@ -357,17 +360,17 @@ internal fun addStateItems( } } -/** Every transition between two placed states, as a curve on the canvas, keyed by its index. */ +/** Every transition between two placed states with its curve on the canvas; a pair repeated in the file counts once. */ private fun AnimationControllerLayerSpec.transitionCurves(boxes: Map, zoom: Float) = - transitions.mapIndexedNotNull { index, transition -> - val from = boxes[transition.from] ?: return@mapIndexedNotNull null - val to = boxes[transition.to] ?: return@mapIndexedNotNull null - if (transition.from == transition.to) return@mapIndexedNotNull null + transitions.distinctBy { it.from to it.to }.mapNotNull { transition -> + val from = boxes[transition.from] ?: return@mapNotNull null + val to = boxes[transition.to] ?: return@mapNotNull null + if (transition.from == transition.to) return@mapNotNull null val twoWay = transitions.any { it.from == transition.to && it.to == transition.from } val side = if (twoWay && transition.from > transition.to) -1f else 1f val offset = if (twoWay) ParallelOffset * zoom * side else 0f - index to GraphCurves.betweenBoxes(from, to, offset, gap = ARROW_SIZE * zoom * 0.5f) + transition to GraphCurves.betweenBoxes(from, to, offset, gap = ARROW_SIZE * zoom * 0.5f) } private fun AnimationControllerLayerSpec.state(stateId: String): AnimationControllerStateSpec? = diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorInspector.kt index 1f0dafae8..7a199bf47 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorInspector.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorInspector.kt @@ -30,9 +30,9 @@ internal fun animatorInspectorTarget( ) { StateSection(document, selection, onSelect) } is AnimatorSelection.Transition -> InspectorTarget( - id = "animator-link-${selection.layerId}-${selection.index}", + id = "animator-link-${selection.layerId}-${selection.from}-${selection.to}", title = document.animator.controller(selection.layerId) - ?.transitions?.getOrNull(selection.index) + ?.transition(selection.from, selection.to) ?.let { "${it.from} β†’ ${it.to}" } ?: animatorText("section_link"), icon = AnimatorIcon, @@ -158,13 +158,12 @@ private fun StateSection( if (state is BlendStateSpec) BlendMotionsSection(document, layerId, state) - val links = controller.transitions.withIndex() - .filter { (_, transition) -> transition.from == state.id || transition.to == state.id } + val links = controller.transitions.filter { it.from == state.id || it.to == state.id } if (links.isNotEmpty()) { Section(animatorText("state_transitions")) { - links.forEach { (index, transition) -> + links.forEach { transition -> InspectorButton("${transition.from} β†’ ${transition.to}") { - onSelect(AnimatorSelection.Transition(layerId, index)) + onSelect(AnimatorSelection.Transition(layerId, transition.from, transition.to)) } } } @@ -187,10 +186,10 @@ private fun TransitionSection( ) { val layerId = selection.layerId val controller = document.animator.controller(layerId) ?: return Hint(animatorText("layer_removed")) - val transition = controller.transitions.getOrNull(selection.index) ?: return Hint(animatorText("transition_removed")) + val transition = controller.transition(selection.from, selection.to) ?: return Hint(animatorText("transition_removed")) fun update(change: (AnimationControllerTransitionSpec) -> AnimationControllerTransitionSpec) { - document.edit { it.withTransitionAt(layerId, selection.index, change(transition)) } + document.edit { it.withTransitionChanged(layerId, selection.from, selection.to, change(transition)) } } Section(animatorText("section_link")) { @@ -211,7 +210,7 @@ private fun TransitionSection( } InspectorButton(animatorText("delete_transition"), tags = listOf("danger")) { - document.edit { it.withoutTransitionAt(layerId, selection.index) } + document.edit { it.withoutTransition(layerId, selection.from, selection.to) } onSelect(AnimatorSelection.Layer(layerId)) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/HollowIdeAnimatorEditor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/HollowIdeAnimatorEditor.kt index 91dab0dfc..4c8d4bcc4 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/HollowIdeAnimatorEditor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/HollowIdeAnimatorEditor.kt @@ -24,6 +24,10 @@ private const val AutoSaveDelayMillis = 900L @Composable internal fun HollowIdeAnimatorEditor(file: HollowIdeOpenFile) { val document = file.document as HollowIdeAnimatorDocument + document.error?.let { message -> + return Text(animatorText("broken_file") + " " + message, tags = listOf("animator-empty-hint"), modifier = Modifier.padding(10.px)) + } + val view = remember(document) { document.editorState { GraphViewState() } } var selection by remember(document) { mutableStateOf(AnimatorSelection.None) } var openLayer by remember(document) { mutableStateOf(document.animator.layers.firstOrNull()?.id) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt index 512dcf006..5f134a80e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageEditorPanel.kt @@ -105,7 +105,7 @@ internal fun MontageEditorPanel(file: HollowIdeOpenFile) { state.sceneLane = null val part = rigState.selectedPart?.takeIf { state.rigs.rig.holder(bone).attachment(it) != null } val attachments = document.montage.attachments.filter { it.bone == bone }.map { it.spec.id } - session.select(bone, attachments + listOfNotNull(part)) + session.select(bone, attachments + listOfNotNull(part), selectedAttachment = part) } val bones = remember(model) { viewer.nodes.flatMap { node -> node.walk().map(RuntimeNode::name) } } @@ -198,7 +198,7 @@ private fun Toolbar(state: MontageEditorState) { IconButton(ColliderIcon, "hollowengine.gui.rig_editor.toggle_colliders".lang, iconSize = 12f, active = rigState.showColliders) { rigState.showColliders = !rigState.showColliders } - if (rigState.partSelection != null) GizmoToolbarButtons() + if (rigState.partSelection != null) GizmoToolbarButtons("montage-gizmo-${System.identityHashCode(state)}") } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt index 57f4e379d..094004336 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/montage/MontageTimelineSession.kt @@ -70,6 +70,7 @@ internal class MontageTimelineSession( /** What is selected in the scene now, as group keys, or the key of a lane picked there. */ private var selection: Set = emptySet() + private var pickedGroup: String? = null private var onlySelectedState by mutableStateOf(false) override var onlySelected: Boolean @@ -259,17 +260,19 @@ internal class MontageTimelineSession( /** Opens [lane], picked in the scene in place of a bone, as what is selected now. */ fun selectLane(lane: SegmentLane) { + pickedGroup = null selection = setOf(laneKey(lane)) lane.isListed = true applyListing() } /** Opens the rows of what is selected in the scene: [bone], or the root, and the attachments [attachments] on it. */ - fun select(bone: String?, attachments: Collection) { + fun select(bone: String?, attachments: Collection, selectedAttachment: String? = null) { + pickedGroup = selectedAttachment?.let { attachmentKey(bone, it) } ?: bone?.let(::boneKey) selection = attachments.mapTo(listOfNotNull(bone?.let(::boneKey)).toMutableSet()) { attachmentKey(bone, it) } bone?.let(::touchBone) attachments.forEach { touchAttachment(bone, it) } - if (onlySelectedState) applyListing() + applyListing() } override fun showAll() { @@ -572,6 +575,7 @@ internal class MontageTimelineSession( } private fun applyListing(group: TrackGroup) { + group.isSelected = pickedGroup != null && groupKeys[group] == pickedGroup group.children.forEach(::applyListing) val key = groupKeys[group] val shown = key == null || !onlySelectedState || key in selection diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt index 2d339f0e5..cd1effa68 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigEditorPanel.kt @@ -123,7 +123,7 @@ private fun Toolbar( IconButton(ColliderIcon, rigText("toggle_colliders"), iconSize = 12f, active = state.showColliders) { state.showColliders = !state.showColliders } - if (state.partSelection != null) GizmoToolbarButtons() + if (state.partSelection != null) GizmoToolbarButtons("rig-gizmo-${System.identityHashCode(state)}") if (RigPreviews.isAvailable) { IconButton( icon = if (preview == null) PhysicsIcon else StopIcon, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt index 7314ff329..7c4234986 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/rig/RigViewport.kt @@ -7,6 +7,7 @@ import ru.hollowhorizon.hollowengine.client.history.UndoKeys import ru.hollowhorizon.hollowengine.client.models.internal.rig.RigPreview import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoPointerWrap +import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoRotationHint import ru.hollowhorizon.hollowengine.client.ui.widgets.Model import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput import ru.hollowhorizon.hollowengine.client.ui.widgets.ViewAxes @@ -21,10 +22,15 @@ internal fun RigViewport( onKey: (UiKeyInput) -> Boolean = { false }, ) { val viewer = state.viewer - Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { + val viewportId = "rig-viewport-${System.identityHashCode(state)}" + Box( + id = "$viewportId-focus", + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).focusScope(), + ) { Model( state = viewer, - id = "rig-viewport", + id = viewportId, tags = listOf("ide-file-viewport"), modifier = Modifier.size(100.percent, 100.percent).clip().input(hoverable = true, clickable = true).focus() .onPlaced { state.viewportBounds = it } @@ -81,7 +87,8 @@ internal fun RigViewport( if (state.gizmo.capture(viewer)) session.draw(this, state.gizmoTarget()) }, ) - ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px)) + ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px).focusScope()) + GizmoRotationHint(session, Modifier.align(UiAlign.START, UiAlign.END).margin(8.px)) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditor.kt index e7567d342..07cfbce48 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditor.kt @@ -3,25 +3,17 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph import androidx.compose.runtime.* import com.mojang.blaze3d.systems.RenderSystem import kotlinx.coroutines.delay -import org.lwjgl.glfw.GLFW -import ru.hollowhorizon.hollowengine.client.history.UndoKeys -import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.shadergraph.* import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.graph.* import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOpenFile import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector -import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect -import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.client.vfx.render.VfxGraphMaterials -import kotlin.math.hypot import kotlin.time.Duration.Companion.milliseconds -internal const val ShaderGraphStylesheet = "hollowengine:ui/styles/shader-graph.hss" private const val AutoSaveDelayMillis = 900L /** @@ -32,18 +24,11 @@ private const val AutoSaveDelayMillis = 900L internal fun ShaderGraphEditor(file: HollowIdeOpenFile) { val document = file.document as HollowIdeShaderGraphDocument val state = remember(document) { document.editorState { ShaderGraphEditorState() } } - val view = state.view + val controller = remember(document) { GraphEditorController(ShaderGraphCanvasId, state.editor) } val previews = remember(document) { ShaderGraphPreviews() } DisposableEffect(previews) { onDispose { RenderSystem.recordRenderCall(previews::release) } } - var link by remember(document) { mutableStateOf(null) } - var menu by remember(document) { mutableStateOf(null) } - var insertInto by remember(document) { mutableStateOf(null) } var previewError by remember(document) { mutableStateOf(null) } - val dragOrigins = remember(document) { HashMap>() } - - /** The node whose corner lands on the grid while a drag snaps; the rest keep their distance to it. */ - val dragAnchor = remember(document) { arrayOfNulls(1) } LaunchedEffect(document.revision) { file.updateDirty(document.isModified) @@ -72,12 +57,12 @@ internal fun ShaderGraphEditor(file: HollowIdeOpenFile) { previews.show(graph, document.revision to kinds) // A group taken apart, from the inspector or by undo, leaves its nodes selected on their own. - val selection = state.selection.let { current -> + val selection = state.editor.selection.let { current -> if (current.group != null && graph.group(current.group) == null) current.copy(group = null) else current } SideEffect { state.diagnostics = diagnostics - if (state.selection != selection) state.selection = selection + if (state.editor.selection != selection) state.editor.selection = selection } PublishInspector(source = "shadergraph-${file.path}", key = selection) { shaderGraphInspectorTarget(document, selection, state) @@ -88,310 +73,15 @@ internal fun ShaderGraphEditor(file: HollowIdeOpenFile) { ShaderNodeTypes.of(node.type)?.let { all[node.id] = ShaderNodeLayout.of(graph, node, it, code.types) } } val scene = ShaderGraphScene.of(graph, all) - val boxes = scene.boxes - val pins = scene.pins - val curves = graph.links.mapIndexedNotNull { index, each -> - val from = pins.firstOrNull { it.node == each.from && it.output && it.name == each.output } - ?: return@mapIndexedNotNull null - val to = pins.firstOrNull { it.node == each.to && !it.output && it.name == each.input } - ?: return@mapIndexedNotNull null - index to GraphCurves.betweenPins( - view.toCanvasX(from.x), view.toCanvasY(from.y), view.toCanvasX(to.x), view.toCanvasY(to.y), view.zoom, - ) - } - - fun select(next: ShaderGraphSelection?) { - if (next != null) state.selection = next - } - - fun membersOf(group: String): Set = graph.group(group)?.nodes?.filter { it in all }?.toSet().orEmpty() - - fun pinAt(x: Float, y: Float, outputs: Boolean): ShaderPin? = - pins.filter { it.output == outputs }.map { it to hypot(view.toCanvasX(it.x) - x, view.toCanvasY(it.y) - y) } - .filter { it.second <= PIN_REACH * view.zoom.coerceAtLeast(1f) }.minByOrNull { it.second }?.first - - fun nodeAt(x: Float, y: Float): ShaderNodeBox? = boxes.values.lastOrNull { it.rect.toCanvas(view).contains(x, y) } - - fun cardAt(x: Float, y: Float): ShaderGroupCard? = scene.cards.values.lastOrNull { it.rect.toCanvas(view).contains(x, y) } - - /** A node of [kind] added at ([x], [y]) of the graph and selected. */ - fun addNode(kind: ShaderNodeType, x: Float, y: Float): ShaderGraphNode { - val node = ShaderGraphNode(document.graph.freeNodeId(kind.id), kind.id, x, y) - document.edit { it.withNode(node) } - select(ShaderGraphSelection.of(node.id)) - return node - } - - /** - * A link dropped on nothing: the add menu, showing only what can take the link, and the node - * picked from it placed where the link was dropped and linked. A reroute can always take it. - */ - fun offerNodeFor(pending: PendingLink) { - val graphX = view.toGraphX(pending.x) - val graphY = view.toGraphY(pending.y) - val half = ShaderNodeLayout.REROUTE / 2f - val items = if (pending.fromOutput) { - val carried = code.types.output(pending.node, pending.pin) ?: ShaderType.FLOAT - val reroute = rerouteItem { - val node = addNode(ShaderNodeTypes.of(ShaderNodeLibrary.REROUTE) ?: return@rerouteItem, graphX - half, graphY - half) - document.edit { it.withLink(pending.node, pending.pin, node.id, ShaderNodeLibrary.REROUTE_INPUT) } - } - listOf(reroute) + addNodeItems(graph.target, { it.inputFor(carried) != null }) { kind -> - val node = addNode(kind, graphX, graphY - ShaderNodeLayout.HEADER) - kind.inputFor(carried, node) - ?.let { pin -> document.edit { it.withLink(pending.node, pending.pin, node.id, pin.name) } } - } - } else { - val into = - graph.node(pending.node)?.let { node -> ShaderNodeTypes.of(node.type)?.input(node, pending.pin)?.type } - ?: return - val reroute = rerouteItem { - val node = addNode(ShaderNodeTypes.of(ShaderNodeLibrary.REROUTE) ?: return@rerouteItem, graphX - half, graphY - half) - document.edit { it.withLink(node.id, ShaderNodeLibrary.REROUTE_OUTPUT, pending.node, pending.pin) } - } - listOf(reroute) + addNodeItems(graph.target, { it.outputFor(into) != null }) { kind -> - val node = addNode(kind, graphX - ShaderNodeLayout.WIDTH, graphY - ShaderNodeLayout.HEADER) - kind.outputFor(into) - ?.let { output -> document.edit { it.withLink(node.id, output.name, pending.node, pending.pin) } } - } - } - menu = GraphMenu(pending.screenX, pending.screenY, items.mapIndexed { index, item -> if (index == 1) item.copy(separatorBefore = true) else item }) - } - - /** - * Ends a link being drawn: dropped on a pin, it links to it; on a node, to the first of its pins - * that takes it; on a collapsed group, nowhere; on nothing, it offers the nodes that could. - */ - fun finishLink() { - val pending = link ?: return - link = null - val pin = pinAt(pending.x, pending.y, outputs = !pending.fromOutput) - val node = nodeAt(pending.x, pending.y)?.takeIf { it.node.id != pending.node } - when { - pin != null -> document.edit { - if (pending.fromOutput) it.withLink(pending.node, pending.pin, pin.node, pin.name) - else it.withLink(pin.node, pin.name, pending.node, pending.pin) - } - - node != null && pending.fromOutput -> { - val carried = code.types.output(pending.node, pending.pin) ?: ShaderType.FLOAT - val free = node.kind.inputs(node.node).filter { graph.linkInto(node.node.id, it.name) == null } - val target = - free.firstOrNull { it.type.fixed == carried } ?: free.firstOrNull { it.type.accepts(carried) } - if (target != null) document.edit { it.withLink(pending.node, pending.pin, node.node.id, target.name) } - } - - node != null -> { - val into = graph.node(pending.node) - ?.let { owner -> ShaderNodeTypes.of(owner.type)?.input(owner, pending.pin)?.type } - val output = into?.let { node.kind.outputFor(it) } - if (output != null) document.edit { it.withLink(node.node.id, output.name, pending.node, pending.pin) } - } - - cardAt(pending.x, pending.y) != null -> Unit - else -> offerNodeFor(pending) - } - document.endGesture() - } - - /** Puts [nodeId] into the link at [index]: whatever fed the link feeds the node, and the node feeds where the link went. */ - fun insert(index: Int, nodeId: String) { - val current = document.graph - val target = current.links.getOrNull(index) ?: return - val node = current.node(nodeId) ?: return - val kind = ShaderNodeTypes.of(node.type) ?: return - val input = kind.inputFor(code.types.output(target.from, target.output) ?: ShaderType.FLOAT, node) ?: return - val into = current.node(target.to)?.let { ShaderNodeTypes.of(it.type)?.input(it, target.input)?.type } ?: return - val output = kind.outputFor(into) ?: return - document.edit { - it.withoutLinkAt(index).withLink(target.from, target.output, nodeId, input.name) - .withLink(nodeId, output.name, target.to, target.input) - } - } - - /** Ends a drag of nodes: one dropped on a link goes into it, and the whole drag is one step back. */ - fun finishDrag() { - if (dragOrigins.isEmpty()) return - insertInto?.let { index -> dragOrigins.keys.singleOrNull()?.let { insert(index, it) } } - insertInto = null - joinFrame(document, scene, all, dragOrigins) - dragOrigins.clear() - document.endGesture() - } - - /** Takes hold of the selected nodes for a drag that [anchor] leads. */ - fun beginDrag(anchor: String?) { - dragOrigins.clear() - state.selection.nodes.forEach { id -> document.graph.node(id)?.let { dragOrigins[id] = it.x to it.y } } - dragAnchor[0] = anchor - document.beginGesture() - } - - /** A group taken by its title bar leaves the nodes; the nodes themselves go, the output node excepted. */ - fun deleteSelection() { - val index = selection.link - val group = selection.group - when { - index != null -> document.edit { it.withoutLinkAt(index) } - group != null -> document.edit { it.withoutGroup(group) } - else -> { - val removable = selection.nodes.filter { all[it]?.kind?.master == null }.toSet() - if (removable.isEmpty()) return - document.edit { it.withoutNodes(removable) } - } - } - select(ShaderGraphSelection.None) - } - - fun deleteGroupWithNodes(group: String) { - val removable = membersOf(group).filter { all[it]?.kind?.master == null }.toSet() - document.edit { it.withoutGroup(group).withoutNodes(removable) } - select(ShaderGraphSelection.None) - } - - fun duplicateSelection() { - val copied = selection.nodes.filter { all[it]?.kind?.master == null }.toSet() - if (copied.isEmpty()) return - val (next, copies) = document.graph.withDuplicates(copied) - document.edit { next } - select(ShaderGraphSelection(copies.toSet())) - } - - fun flipGroup(group: String) = document.edit { graph -> graph.withGroupChanged(group) { it.copy(collapsed = !it.collapsed) } } - - fun handleKey(input: UiKeyInput): Boolean { - when { - HollowKeys.DELETE.matches(input) -> deleteSelection() - HollowKeys.DUPLICATE.matches(input) -> duplicateSelection() - HollowKeys.SELECT_ALL.matches(input) -> select(ShaderGraphSelection(all.keys.toSet())) - HollowKeys.GROUP.matches(input) -> select(document.groupNodes(selection.nodes)) - HollowKeys.UNGROUP.matches(input) -> select(document.ungroup(selection)) - - UndoKeys.handle(document.history, input.key, input.modifiers) -> Unit - input.key == GLFW.GLFW_KEY_ESCAPE -> if (link != null) { - link = null - document.endGesture() - } else { - select(ShaderGraphSelection.None) - } - - else -> return false - } - return true - } + val model = ShaderGraphEditorModel(document, code, scene) { controller.select(it) } + controller.model = model val actions = object : ShaderNodeActions { - override fun pressNode(node: String, gesture: GraphNodeGesture) { - menu = null - when { - gesture.shift || gesture.control -> { - val nodes = if (node in selection) selection.nodes - node else selection.nodes + node - select(ShaderGraphSelection(nodes)) - } - - node !in selection || selection.group != null -> select(ShaderGraphSelection.of(node)) - } - beginDrag(node) - } - - override fun pressGroup(group: String, gesture: GraphNodeGesture) { - menu = null - val members = membersOf(group) - when { - gesture.shift || gesture.control -> { - val nodes = if (members.all { it in selection }) selection.nodes - members else selection.nodes + members - select(ShaderGraphSelection(nodes)) - } - - selection.group != group -> select(ShaderGraphSelection(members, group = group)) - } - beginDrag(members.firstOrNull()) - } - - override fun dragNode(node: String, gesture: GraphNodeGesture) { - if (link != null || dragOrigins.isEmpty()) return - val anchor = dragAnchor[0]?.let(dragOrigins::get) ?: dragOrigins.values.first() - val dx = GraphPreferences.place(anchor.first + gesture.graphDeltaX) - anchor.first - val dy = GraphPreferences.place(anchor.second + gesture.graphDeltaY) - anchor.second - document.edit { graph -> - graph.withNodesAt(dragOrigins.mapValues { (_, at) -> at.first + dx to at.second + dy }) - } - val lone = dragOrigins.keys.singleOrNull() - ?.takeIf { id -> document.graph.links.none { it.from == id || it.to == id } } - insertInto = lone?.let { GraphCurves.nearest(curves, gesture.canvasX, gesture.canvasY, INSERT_REACH) } - } - - override fun releaseNode(node: String) = finishDrag() - - override fun nodeMenu(node: String, screenX: Float, screenY: Float) { - if (node !in selection) select(ShaderGraphSelection.of(node)) - val box = all[node] ?: return - val chosen = state.selection - menu = GraphMenu( - screenX, screenY, - nodeItems( - box.node, box.kind, - onPreview = { shown -> document.edit { it.withPreview(node, shown) } }, - onDuplicate = ::duplicateSelection, - onDelete = ::deleteSelection, - organize = organizeItems( - canGroup = chosen.nodes.isNotEmpty(), - grouped = chosen.nodes.any { graph.groupOf(it) != null }, - onGroup = { select(document.groupNodes(chosen.nodes)) }, - onUngroup = { select(document.ungroup(chosen)) }, - onLeave = { document.edit { it.withMembership(chosen.nodes, group = null) } }, - arrange = document.arrangeItem(boxes, chosen.nodes), - ), - ), - ) - } - - override fun groupMenu(group: String, screenX: Float, screenY: Float) { - val found = graph.group(group) ?: return - val members = membersOf(group) - if (selection.group != group) select(ShaderGraphSelection(members, group = group)) - menu = GraphMenu( - screenX, screenY, - groupItems( - found, - onToggle = { flipGroup(group) }, - onUngroup = { select(document.ungroup(ShaderGraphSelection(members, group = group))) }, - onDelete = { deleteGroupWithNodes(group) }, - arrange = if (found.collapsed) null else document.arrangeItem(boxes, members), - ), - ) - } - override fun toggleCollapsed(node: String) { val collapsed = document.graph.node(node)?.collapsed ?: return document.edit { it.withCollapsed(node, !collapsed) } } - override fun toggleGroup(group: String) = flipGroup(group) - - override fun pressPin(pin: ShaderPin, screenX: Float, screenY: Float) { - menu = null - document.beginGesture() - val x = view.toCanvasX(pin.x) - val y = view.toCanvasY(pin.y) - val existing = if (pin.output) null else graph.linkInto(pin.node, pin.name) - link = when { - pin.output -> PendingLink(pin.node, pin.name, true, x, y, screenX, screenY) - existing != null -> { - document.edit { it.withoutLinkInto(pin.node, pin.name) } - PendingLink(existing.from, existing.output, true, x, y, screenX, screenY) - } - - else -> PendingLink(pin.node, pin.name, false, x, y, screenX, screenY) - } - } - - override fun dragPin(canvasX: Float, canvasY: Float, screenX: Float, screenY: Float) { - link = link?.copy(x = canvasX, y = canvasY, screenX = screenX, screenY = screenY) - } - - override fun releasePin() = finishLink() - override fun setValue(node: String, pin: String, values: List) = document.edit { it.withValue(node, pin, values) } @@ -399,7 +89,7 @@ internal fun ShaderGraphEditor(file: HollowIdeOpenFile) { document.edit { it.withOption(node, option, value) } override fun openMenu(screenX: Float, screenY: Float, items: List) { - menu = GraphMenu(screenX, screenY, items) + controller.menu = GraphMenu(screenX, screenY, items) } override fun beginGesture() = document.beginGesture() @@ -408,104 +98,13 @@ internal fun ShaderGraphEditor(file: HollowIdeOpenFile) { Box( mode = UiBoxMode.STACK, - modifier = Modifier.size(100.percent, 100.percent).style(ShaderGraphStylesheet).focusScope() + modifier = Modifier.size(100.percent, 100.percent).style(GraphNodesStylesheet).focusScope() .drawBehind(key = previews) { drawGl { previews.render() } }, ) { - GraphCanvas( - id = ShaderGraphCanvasId, - view = view, - modifier = Modifier.size(100.percent, 100.percent), - edges = curves.map { (index, curve) -> - val each = graph.links[index] - GraphEdge( - key = index, - curve = curve, - color = code.types.output(each.from, each.output)?.color() ?: GraphLinkColor, - selected = selection.link == index || insertInto == index, - ) - }, - link = link?.let { pending -> - val pin = - pins.firstOrNull { it.node == pending.node && it.name == pending.pin && it.output == pending.fromOutput } - ?: return@let null - val fromX = view.toCanvasX(pin.x) - val fromY = view.toCanvasY(pin.y) - val type = if (pending.fromOutput) code.types.output(pin.node, pin.name) else code.types.input( - pin.node, pin.name - ) - val curve = - if (pending.fromOutput) GraphCurves.betweenPins(fromX, fromY, pending.x, pending.y, view.zoom) - else GraphCurves.betweenPins(pending.x, pending.y, fromX, fromY, view.zoom) - GraphLinkPreview(fromX, fromY, pending.x, pending.y, type?.color() ?: GraphLinkColor, curve) - }, - frames = scene.frames.mapNotNull { (id, rect) -> - graph.group(id)?.let { GraphFrame(rect, GraphGroupColors.of(it.color)) } - }, - minimap = boxes.values.map { box -> - GraphMiniMapItem( - box.rect, if (box.node.id in selection) GraphSelectedColor else GraphLinkColor, box.kind.title() - ) - } + scene.cards.values.map { card -> - GraphMiniMapItem(card.rect, GraphGroupColors.of(card.group.color), card.group.title.ifBlank { graphText("group") }) - }, - onBackgroundClick = { - menu = null - select(ShaderGraphSelection.None) - }, - onEdgeClick = { index -> select(ShaderGraphSelection.ofLink(index as Int)) }, - onEdgeDoubleClick = { index, pointer -> select(document.addReroute(index as Int, pointer.graphX, pointer.graphY)) }, - onContextMenu = { pointer, edge -> - menu = if (edge is Int) { - select(ShaderGraphSelection.ofLink(edge)) - GraphMenu( - pointer.screenX, - pointer.screenY, - listOf( - rerouteItem { select(document.addReroute(edge, pointer.graphX, pointer.graphY)) }, - UiDropdownItem(graphText("delete_link"), shortcut = HollowKeys.DELETE.shortcut) { - document.edit { it.withoutLinkAt(edge) } - select(ShaderGraphSelection.None) - }, - ), - ) - } else { - GraphMenu( - pointer.screenX, - pointer.screenY, - addNodeItems(graph.target) { kind -> addNode(kind, pointer.graphX, pointer.graphY) } + graphViewItems(), - ) - } - }, - onRelease = { - finishLink() - finishDrag() - }, - onSelectArea = { area, modifiers -> - val covered = boxes.values.filter { it.rect.intersects(area) }.map { it.node.id } + - scene.cards.values.filter { it.rect.intersects(area) }.flatMap { membersOf(it.group.id) } - val additive = modifiers and (GLFW.GLFW_MOD_SHIFT or GLFW.GLFW_MOD_CONTROL) != 0 - select(ShaderGraphSelection(if (additive) selection.nodes + covered else covered.toSet())) - }, - onKey = ::handleKey, - ) { - ShaderGraphSceneView(graph, scene, code.types, view, selection, problems, previews, actions) - } - - menu?.let { open -> - ContextMenu( - id = "shader-graph-menu", - anchorBounds = UiRect(open.x, open.y, 0f, 0f), - items = open.items, - onExpandedChange = { if (!it) menu = null }, - ) + GraphEditorCanvas(controller, Modifier.size(100.percent, 100.percent)) { + ShaderGraphSceneView(graph, model, controller, code.types, problems, previews, actions) } } } internal fun graphText(name: String): String = "hollowengine.gui.shadergraph.$name".lang - -/** How far from a pin, in canvas pixels at zoom 1, a link can be dropped and still land on it. */ -private const val PIN_REACH = 12f - -/** How close to a link, in canvas pixels, a lone node has to be dragged to go into it. */ -private const val INSERT_REACH = 10f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditorModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditorModel.kt new file mode 100644 index 000000000..9e093d193 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditorModel.kt @@ -0,0 +1,212 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +import ru.hollowhorizon.hollowengine.client.history.UndoHistory +import ru.hollowhorizon.hollowengine.client.shadergraph.* +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.graph.* +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark + +/** A material graph as [GraphEditorController] sees it, made anew for each graph drawn. */ +internal class ShaderGraphEditorModel( + private val document: HollowIdeShaderGraphDocument, + private val code: ShaderGraphCode, + val nodes: ShaderGraphScene, + private val select: (GraphSelection) -> Unit, +) : GraphEditorModel { + private val graph = document.graph + + override val scene: GraphEditorScene = GraphEditorScene( + boxes = nodes.boxes.mapValues { it.value.rect }, + titles = nodes.boxes.mapValues { it.value.kind.title() }, + reroutes = nodes.boxes.filterValues { it.isReroute }.keys, + pins = nodes.pins, + links = graph.links.mapNotNull { each -> + val from = nodes.pins.firstOrNull { it.node == each.from && it.output && it.name == each.output } + ?: return@mapNotNull null + val to = nodes.pins.firstOrNull { it.node == each.to && !it.output && it.name == each.input } + ?: return@mapNotNull null + GraphLinkView(each, from, to, code.types.output(each.from, each.output)?.color() ?: GraphLinkColor) + }, + frames = nodes.frames.mapNotNull { (id, rect) -> + graph.group(id)?.let { GraphFrameView(id, it.title.ifBlank { graphEditorText("group") }, GraphGroupColors.of(it.color), rect) } + }, + cards = nodes.cards.values.map { card -> + GraphCardView( + card.group.id, card.group.title.ifBlank { graphEditorText("group") }, GraphGroupColors.of(card.group.color), + card.rect, card.inputs, card.outputs, + ) + }, + ) + + override val history: UndoHistory get() = document.history + + override fun beginGesture() = document.beginGesture() + override fun endGesture() = document.endGesture() + override fun cancelGesture() = document.cancelGesture() + + override fun positionOf(node: String) = document.graph.node(node)?.let { it.x to it.y } + + override fun moveNodes(positions: Map>) = document.edit { it.withNodesAt(positions) } + + override fun pinColor(pin: GraphPin): UiColor = typeOf(pin)?.color() ?: GraphLinkColor + + fun typeOf(pin: GraphPin): ShaderType? = if (pin.output) code.types.output(pin.node, pin.name) else code.types.input(pin.node, pin.name) + + override fun connect(from: GraphPin, to: GraphPin) { + val output = if (from.output) from else to + val input = if (from.output) to else from + document.edit { it.withLink(output.node, output.name, input.node, input.name) } + } + + override fun connectToNode(pending: GraphPendingLink, node: String) { + val box = nodes.boxes[node] ?: return + if (pending.fromOutput) { + val free = box.kind.inputs(box.node).filter { graph.linkInto(node, it.name) == null } + val carried = carried(pending) + val target = free.firstOrNull { it.type.fixed == carried } ?: free.firstOrNull { it.type.accepts(carried) } ?: return + document.edit { it.withLink(pending.node, pending.pin, node, target.name) } + } else { + val output = intoType(pending)?.let(box.kind::outputFor) ?: return + document.edit { it.withLink(node, output.name, pending.node, pending.pin) } + } + } + + override fun detach(input: GraphPin): GraphPin? { + val existing = graph.linkInto(input.node, input.name) ?: return null + document.edit { it.withoutLinkInto(input.node, input.name) } + return scene.pins.firstOrNull { it.node == existing.from && it.output && it.name == existing.output } + } + + private fun carried(pending: GraphPendingLink) = code.types.output(pending.node, pending.pin) ?: ShaderType.FLOAT + + private fun intoType(pending: GraphPendingLink) = + graph.node(pending.node)?.let { owner -> ShaderNodeTypes.of(owner.type)?.input(owner, pending.pin)?.type } + + /** A new node at ([x], [y]), with what [then] makes of the graph in the same step back. */ + private fun add( + kind: ShaderNodeType, + x: Float, + y: Float, + then: ShaderGraph.(ShaderGraphNode) -> ShaderGraph = { this }, + ) { + val node = ShaderGraphNode(document.graph.freeNodeId(kind.id), kind.id, x, y) + document.edit { it.withNode(node).then(node) } + select(GraphSelection.of(node.id)) + } + + /** The add menu, showing only what can take the link, and a reroute, which always can. */ + override fun offerNodes(pending: GraphPendingLink, x: Float, y: Float): List { + val half = ShaderNodeLayout.REROUTE / 2f + val rerouteKind = ShaderNodeTypes.of(ShaderNodeLibrary.REROUTE) + val items = if (pending.fromOutput) { + val carried = carried(pending) + val reroute = graphRerouteItem { + add(rerouteKind ?: return@graphRerouteItem, x - half, y - half) { node -> + withLink(pending.node, pending.pin, node.id, ShaderNodeLibrary.REROUTE_INPUT) + } + } + listOf(reroute) + addNodeItems(graph.target, { it.inputFor(carried) != null }) { kind -> + add(kind, x, y - ShaderNodeLayout.HEADER) { node -> + kind.inputFor(carried, node)?.let { pin -> withLink(pending.node, pending.pin, node.id, pin.name) } ?: this + } + } + } else { + val into = intoType(pending) ?: return emptyList() + val reroute = graphRerouteItem { + add(rerouteKind ?: return@graphRerouteItem, x - half, y - half) { node -> + withLink(node.id, ShaderNodeLibrary.REROUTE_OUTPUT, pending.node, pending.pin) + } + } + listOf(reroute) + addNodeItems(graph.target, { it.outputFor(into) != null }) { kind -> + add(kind, x - ShaderNodeLayout.WIDTH, y - ShaderNodeLayout.HEADER) { node -> + kind.outputFor(into)?.let { output -> withLink(node.id, output.name, pending.node, pending.pin) } ?: this + } + } + } + return items.mapIndexed { index, item -> if (index == 1) item.copy(separatorBefore = true) else item } + } + + override fun isLinked(node: String): Boolean = document.graph.links.any { it.from == node || it.to == node } + + /** Whatever fed the link feeds the node, and the node feeds where the link went. */ + override fun insertIntoLink(link: Any, node: String) { + val target = link as ShaderGraphLink + val current = document.graph + if (target !in current.links) return + val placed = current.node(node) ?: return + val kind = ShaderNodeTypes.of(placed.type) ?: return + val input = kind.inputFor(code.types.output(target.from, target.output) ?: ShaderType.FLOAT, placed) ?: return + val into = current.node(target.to)?.let { ShaderNodeTypes.of(it.type)?.input(it, target.input)?.type } ?: return + val output = kind.outputFor(into) ?: return + document.edit { + it.withoutLink(target).withLink(target.from, target.output, node, input.name) + .withLink(node, output.name, target.to, target.input) + } + } + + override fun removeLink(link: Any) = document.edit { it.withoutLink(link as ShaderGraphLink) } + + override fun addReroute(link: Any, x: Float, y: Float): GraphSelection { + val id = graph.freeNodeId(ShaderNodeLibrary.REROUTE) + val half = ShaderNodeLayout.REROUTE / 2f + document.edit { it.withReroute(link as ShaderGraphLink, id, x - half, y - half) } + return GraphSelection.of(id) + } + + override fun removeNodes(nodes: Set) = document.edit { it.withoutNodes(nodes) } + + override fun duplicate(nodes: Set): Set { + val (next, copies) = document.graph.withDuplicates(nodes) + document.edit { next } + return copies.toSet() + } + + override fun groupMembers(group: String): Set = + graph.group(group)?.nodes?.filter { graph.node(it) != null }?.toSet().orEmpty() + + override fun isGrouped(node: String): Boolean = graph.groupOf(node) != null + + override fun groupNodes(nodes: Set): GraphSelection? { + if (nodes.isEmpty()) return null + val (next, id) = graph.withGroup(nodes, graphEditorText("group")) + if (id.isEmpty()) return null + document.edit { next } + return GraphSelection(nodes, group = id) + } + + /** Takes apart the group of [selection], or every group its nodes are in; the nodes stay selected. */ + override fun ungroup(selection: GraphSelection): GraphSelection? { + val groups = selection.group?.let(::listOf) ?: selection.nodes.mapNotNull { graph.groupOf(it)?.id }.distinct() + if (groups.isEmpty()) return null + document.edit { graph -> groups.fold(graph) { current, id -> current.withoutGroup(id) } } + return GraphSelection(selection.nodes) + } + + override fun setMembership(nodes: Set, group: String?) = document.edit { it.withMembership(nodes, group) } + + override fun removeGroup(group: String) = document.edit { it.withoutGroup(group) } + + override fun toggleGroup(group: String) = + document.edit { graph -> graph.withGroupChanged(group) { it.copy(collapsed = !it.collapsed) } } + + override fun isCollapsed(group: String): Boolean = graph.group(group)?.collapsed == true + + override fun nodeItems(node: String): List { + val box = nodes.boxes[node] ?: return emptyList() + if (box.isReroute) return emptyList() + val shown = box.kind.showsPreview(box.node) + return listOf( + UiDropdownItem(graphText("show_preview"), mark = UiDropdownMark.CHECKBOX, checked = shown) { + document.edit { it.withPreview(node, !shown) } + }, + ) + } + + /** The output node stays. */ + override fun isRemovable(node: String): Boolean = + graph.node(node)?.let { ShaderNodeTypes.of(it.type) }?.master == null + + override fun addItems(x: Float, y: Float): List = addNodeItems(graph.target) { kind -> add(kind, x, y) } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphFields.kt index b62f5d374..8ebb3d9c7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphFields.kt @@ -7,6 +7,7 @@ import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphNode import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderOptionKind import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderOptionSpec import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeInsight import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark @@ -28,10 +29,10 @@ internal fun OptionField( actions: ShaderFieldActions, ) { val current = node.options[option.name] ?: option.default - val id = "sg-option-${node.id}-${option.name}" + val id = "gn-option-${node.id}-${option.name}" when (option.kind) { ShaderOptionKind.TOGGLE -> Row( - tags = listOf("sg-toggle"), + tags = listOf("gn-toggle"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f).alignItems(vertical = UiAlign.CENTER) .input(hoverable = true, clickable = true).onClick { event -> if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) actions.setOption( @@ -45,16 +46,16 @@ internal fun OptionField( Checkbox( checked = current == "true", id = "$id-box", - tags = listOf("sg-checkbox"), + tags = listOf("gn-checkbox"), onCheckedChange = { checked -> actions.setOption(node.id, option.name, checked.toString()) }, ) - Text(optionTitle(option), tags = listOf("sg-pin-label")) + Text(optionTitle(option), tags = listOf("gn-pin-label")) } ShaderOptionKind.CHOICE -> { Text( optionTitle(option), - tags = listOf("sg-pin-label"), + tags = listOf("gn-pin-label"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f) ) Chip(id, optionValueTitle(option, current)) { x, y -> @@ -81,14 +82,14 @@ internal fun OptionField( ShaderOptionKind.TEXT -> { Text( optionTitle(option), - tags = listOf("sg-pin-label"), + tags = listOf("gn-pin-label"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f) ) TextField( value = current, id = id, fontSize = 9f, - tags = listOf("sg-field"), + tags = listOf("gn-field"), modifier = Modifier.size(FieldsWidth.px, FieldHeight.px), onChange = { actions.setOption(node.id, option.name, it) }, ) @@ -98,10 +99,12 @@ internal fun OptionField( value = current, id = id, fontSize = 9f, - syntaxHighlighter = ShaderExpressionEditing.highlighter, - completionContributor = ShaderExpressionEditing.completions, - diagnostics = ShaderExpressionEditing.diagnostics(current), - tags = listOf("sg-field", "sg-expression"), + insight = UiCodeInsight( + highlighter = ShaderExpressionEditing.highlighter, + completions = ShaderExpressionEditing.completions, + diagnostics = ShaderExpressionEditing.diagnostics(current), + ), + tags = listOf("gn-field", "gn-expression"), modifier = Modifier.size(0.px, ExpressionHeight.px).grow(1f), onChange = { actions.setOption(node.id, option.name, it) }, ) @@ -114,7 +117,7 @@ private fun Chip(id: String, text: String, onOpen: (Float, Float) -> Unit) { Text( text, id = id, - tags = listOf("sg-chip"), + tags = listOf("gn-chip"), modifier = Modifier.size(FieldsWidth.px, UiLength.Auto).input(hoverable = true, clickable = true) .onPress { event -> if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onOpen(event.x, event.y) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphInspector.kt index 17b80219c..322d028b8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphInspector.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphInspector.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import ru.hollowhorizon.hollowengine.client.shadergraph.* import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.graph.GraphGroupColors +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphSelection +import ru.hollowhorizon.hollowengine.client.ui.graph.graphEditorText import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument import ru.hollowhorizon.hollowengine.client.ui.inspector.* import ru.hollowhorizon.hollowengine.client.ui.widgets.NumberField @@ -20,18 +22,18 @@ private const val GroupIcon = "hollowengine:textures/gui/icons/actions/group.svg */ internal fun shaderGraphInspectorTarget( document: HollowIdeShaderGraphDocument, - selection: ShaderGraphSelection, + selection: GraphSelection, state: ShaderGraphEditorState, ): InspectorTarget { selection.group?.let(document.graph::group)?.let { group -> - return InspectorTarget(id = "shadergraph-group-${group.id}", title = graphText("group"), subtitle = group.id, icon = GroupIcon) { + return InspectorTarget(id = "shadergraph-group-${group.id}", title = graphEditorText("group"), subtitle = group.id, icon = GroupIcon) { document.graph.group(group.id)?.let { GroupFields(document, it) } } } val selected = selection.single?.let(document.graph::node) val selectedKind = selected?.let { ShaderNodeTypes.of(it.type) } if (selected != null && selectedKind?.id == ShaderNodeLibrary.REROUTE) { - return InspectorTarget(id = "shadergraph-node-${selected.id}", title = graphText("reroute"), subtitle = selected.id, icon = GraphIcon) { + return InspectorTarget(id = "shadergraph-node-${selected.id}", title = graphEditorText("reroute"), subtitle = selected.id, icon = GraphIcon) { Hint(graphText("reroute_hint")) } } @@ -56,7 +58,7 @@ private fun NodeFields(document: HollowIdeShaderGraphDocument, node: ShaderGraph kind.options.forEach { option -> val current = node.options[option.name] ?: option.default fun set(value: String) = document.edit { it.withOption(node.id, option.name, value) } - val id = "sg-inspector-option-${node.id}-${option.name}" + val id = "gn-inspector-option-${node.id}-${option.name}" when (option.kind) { ShaderOptionKind.CHOICE -> { Label(optionTitle(option)) @@ -107,13 +109,13 @@ private fun NodeFields(document: HollowIdeShaderGraphDocument, node: ShaderGraph @Composable private fun GroupFields(document: HollowIdeShaderGraphDocument, group: ShaderGraphGroup) { fun change(update: (ShaderGraphGroup) -> ShaderGraphGroup) = document.edit { it.withGroupChanged(group.id, update) } - Section(graphText("group")) { - TextRow(graphText("group_title"), group.title, id = "sg-group-title-${group.id}") { title -> change { it.copy(title = title) } } + Section(graphEditorText("group")) { + TextRow(graphText("group_title"), group.title, id = "gn-group-title-${group.id}") { title -> change { it.copy(title = title) } } Label(graphText("group_color")) val current = group.color.takeIf { it in GraphGroupColors.all } ?: GraphGroupColors.all.keys.first() Pills(GraphGroupColors.all.keys.toList(), current, { graphText("color.$it") }) { color -> change { it.copy(color = color) } } - ToggleRow(graphText("collapse_group"), group.collapsed) { collapsed -> change { it.copy(collapsed = collapsed) } } - InspectorButton(graphText("ungroup")) { document.edit { it.withoutGroup(group.id) } } + ToggleRow(graphEditorText("collapse_group"), group.collapsed) { collapsed -> change { it.copy(collapsed = collapsed) } } + InspectorButton(graphEditorText("ungroup")) { document.edit { it.withoutGroup(group.id) } } } } @@ -157,16 +159,16 @@ private fun GraphFields(document: HollowIdeShaderGraphDocument, problems: List document.edit { it.withPreviewSettings(preview.copy(rotate = rotate)) } } - TextRow(graphText("preview_texture"), preview.texture, id = "sg-preview-texture") { texture -> + TextRow(graphText("preview_texture"), preview.texture, id = "gn-preview-texture") { texture -> document.edit { it.withPreviewSettings(preview.copy(texture = texture)) } } } ShaderTarget.POST -> { - TextRow(graphText("preview_scene"), preview.scene, id = "sg-preview-scene") { scene -> + TextRow(graphText("preview_scene"), preview.scene, id = "gn-preview-scene") { scene -> document.edit { it.withPreviewSettings(preview.copy(scene = scene)) } } - TextRow(graphText("preview_scene_depth"), preview.sceneDepth, id = "sg-preview-scene-depth") { depth -> + TextRow(graphText("preview_scene_depth"), preview.sceneDepth, id = "gn-preview-scene-depth") { depth -> document.edit { it.withPreviewSettings(preview.copy(sceneDepth = depth)) } } } @@ -193,7 +195,7 @@ private fun PropertyFields(document: HollowIdeShaderGraphDocument, index: Int, p fun change(next: ShaderGraphProperty) = document.edit { it.withPropertyChanged(property.name, next) } Row(tags = listOf("insp-input-row")) { Row(modifier = Modifier.size(0.px).grow(1f)) { - NameRow("", property.name, id = "sg-property-$index") { name -> change(property.copy(name = name)) } + NameRow("", property.name, id = "gn-property-$index") { name -> change(property.copy(name = name)) } } InspectorIconButton(RemoveIcon, graphText("remove_property"), tags = listOf("insp-inline-icon", "danger")) { document.edit { it.withoutProperty(property.name) } @@ -201,7 +203,7 @@ private fun PropertyFields(document: HollowIdeShaderGraphDocument, index: Int, p } Pills(ShaderType.entries, property.type, { it.glsl }) { type -> change(property.copy(type = type)) } if (property.type == ShaderType.TEXTURE) { - TextRow(graphText("texture"), property.texture, id = "sg-property-texture-$index") { texture -> + TextRow(graphText("texture"), property.texture, id = "gn-property-texture-$index") { texture -> change(property.copy(texture = texture)) } } else { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphLayout.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphLayout.kt index 19d590f44..26be6b0f1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphLayout.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphLayout.kt @@ -2,11 +2,10 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph import ru.hollowhorizon.hollowengine.client.shadergraph.* import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNodeMetrics +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphPin import ru.hollowhorizon.hollowengine.client.ui.graph.GraphRect -/** One pin of a placed node, where it sits in graph units. */ -internal data class ShaderPin(val node: String, val name: String, val output: Boolean, val x: Float, val y: Float) - /** What a row of a node shows. */ internal sealed interface ShaderNodeRow { val top: Float @@ -49,7 +48,7 @@ internal class ShaderNodeBox( val isReroute: Boolean get() = kind.id == ShaderNodeLibrary.REROUTE - val pins: List + val pins: List get() = when { isReroute -> reroutePins() node.collapsed -> collapsedPins() @@ -57,51 +56,42 @@ internal class ShaderNodeBox( } /** A reroute is a dot, and a link goes in and comes out of its middle. */ - private fun reroutePins(): List { + private fun reroutePins(): List { val x = node.x + width / 2f val y = node.y + height / 2f return listOf( - ShaderPin(node.id, ShaderNodeLibrary.REROUTE_INPUT, false, x, y), - ShaderPin(node.id, ShaderNodeLibrary.REROUTE_OUTPUT, true, x, y), + GraphPin(node.id, ShaderNodeLibrary.REROUTE_INPUT, false, x, y), + GraphPin(node.id, ShaderNodeLibrary.REROUTE_OUTPUT, true, x, y), ) } - private fun rowPins(): List = rows.mapNotNull { row -> + private fun rowPins(): List = rows.mapNotNull { row -> val y = node.y + row.top + row.height / 2f when (row) { - is ShaderNodeRow.Output -> ShaderPin(node.id, row.name, true, node.x + width, y) - is ShaderNodeRow.Input -> ShaderPin(node.id, row.pin.name, false, node.x, y) - is ShaderNodeRow.Value -> ShaderPin(node.id, row.pin.name, false, node.x, y) + is ShaderNodeRow.Output -> GraphPin(node.id, row.name, true, node.x + width, y) + is ShaderNodeRow.Input -> GraphPin(node.id, row.pin.name, false, node.x, y) + is ShaderNodeRow.Value -> GraphPin(node.id, row.pin.name, false, node.x, y) is ShaderNodeRow.Option -> null } } /** A collapsed node has no rows, so what is linked to it reaches the middle of its title bar. */ - private fun collapsedPins(): List { + private fun collapsedPins(): List { val y = node.y + ShaderNodeLayout.COLLAPSED / 2f - return kind.outputs.map { ShaderPin(node.id, it.name, true, node.x + width, y) } + - kind.inputs(node).map { ShaderPin(node.id, it.name, false, node.x, y) } + return kind.outputs.map { GraphPin(node.id, it.name, true, node.x + width, y) } + + kind.inputs(node).map { GraphPin(node.id, it.name, false, node.x, y) } } } internal object ShaderNodeLayout { - const val WIDTH = 216f - - /** The title bar of a collapsed node, which is all of it; an open node has a line under it too. */ - const val COLLAPSED = 32f - const val HEADER = COLLAPSED + 1f - - /** A row that is a name alone; one with a field to edit is [FIELD_ROW] tall. */ - const val ROW = 21f - const val FIELD_ROW = 27f - - /** How far the rows sit from the edges of the node, sideways and above the first and under the last. */ - const val INSET = 12f - const val PADDING = 10f - const val PIN = 9f - - /** How wide a reroute dot is. */ - const val REROUTE = 12f + const val WIDTH = GraphNodeMetrics.WIDTH + const val COLLAPSED = GraphNodeMetrics.COLLAPSED + const val HEADER = GraphNodeMetrics.HEADER + const val ROW = GraphNodeMetrics.ROW + const val FIELD_ROW = GraphNodeMetrics.FIELD_ROW + const val INSET = GraphNodeMetrics.INSET + const val PADDING = GraphNodeMetrics.PADDING + const val REROUTE = GraphNodeMetrics.REROUTE fun of(graph: ShaderGraph, node: ShaderGraphNode, kind: ShaderNodeType, types: ShaderGraphTypes): ShaderNodeBox { if (kind.id == ShaderNodeLibrary.REROUTE) return ShaderNodeBox(node, kind, REROUTE, REROUTE, emptyList(), 0f, 0f, 0f) @@ -142,7 +132,7 @@ internal object ShaderNodeLayout { } } -/** The colors of the pins and of the links between them; the pin rules of `shader-graph.hss` use the same. */ +/** The colors of the pins and of the links between them; the pin rules of `graph-nodes.hss` use the same. */ internal fun ShaderType.color(): UiColor = when (this) { ShaderType.FLOAT -> rgb(0xA5ABC0) ShaderType.VEC2 -> rgb(0x44BC74) @@ -155,10 +145,7 @@ private fun rgb(value: Int) = UiColor.fromArgb(OPAQUE or value) private const val OPAQUE = 0xFF shl 24 -internal val GraphLinkColor = rgb(0xA5ABC0) -internal val GraphSelectedColor = UiColor(0.43f, 0.61f, 0.86f) - -internal fun ShaderType.tag(): String = "sg-type-${name.lowercase()}" +internal fun ShaderType.tag(): String = "gn-type-${name.lowercase()}" /** The icon of the nodes of a category that have none of their own. */ internal fun ShaderNodeCategory.icon(): String = graphIcon( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphMenus.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphMenus.kt index b11f4bcb1..7620b81e3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphMenus.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphMenus.kt @@ -1,14 +1,10 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph -import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.shadergraph.* import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark import ru.hollowhorizon.hollowengine.client.utils.lang -/** A context menu the editor has open, where it was opened on the screen. */ -internal data class GraphMenu(val x: Float, val y: Float, val items: List) - /** * The add menu: a submenu per category, in the order the kinds were registered, with the kinds of one * group together and a line between groups. Kinds that read what [target] does not offer are left out. @@ -36,68 +32,6 @@ internal fun addNodeItems( ) } -/** The item that puts a reroute where a link was dropped, or on the link that was clicked. */ -internal fun rerouteItem(onPick: () -> Unit) = UiDropdownItem(graphText("reroute"), icon = RerouteIcon, onClick = onPick) - -/** - * What the menu of a node offers: its preview, and unless it is the output, a copy and removal; then - * [organize], the grouping and arranging of the selection it belongs to. - */ -internal fun nodeItems( - node: ShaderGraphNode, - kind: ShaderNodeType, - onPreview: (Boolean) -> Unit, - onDuplicate: () -> Unit, - onDelete: () -> Unit, - organize: List, -): List = buildList { - if (kind.id != ShaderNodeLibrary.REROUTE) { - val shown = kind.showsPreview(node) - add(UiDropdownItem(graphText("show_preview"), mark = UiDropdownMark.CHECKBOX, checked = shown) { onPreview(!shown) }) - } - if (kind.master == null) { - add(UiDropdownItem(graphText("duplicate"), shortcut = HollowKeys.DUPLICATE.shortcut, separatorBefore = isNotEmpty(), onClick = onDuplicate)) - add(UiDropdownItem(graphText("delete_node"), shortcut = HollowKeys.DELETE.shortcut, onClick = onDelete)) - } - organize.forEachIndexed { index, item -> add(if (index == 0) item.copy(separatorBefore = true) else item) } -} - -/** - * Grouping the selection: putting it in a new group, and, when it is [grouped], taking it out of its - * groups or taking them apart; then [arrange], lining it up. Empty when there is nothing to do. - */ -internal fun organizeItems( - canGroup: Boolean, - grouped: Boolean, - onGroup: () -> Unit, - onUngroup: () -> Unit, - onLeave: () -> Unit, - arrange: UiDropdownItem?, -): List = listOfNotNull( - UiDropdownItem(graphText("group_nodes"), icon = GroupIcon, shortcut = HollowKeys.GROUP.shortcut, onClick = onGroup).takeIf { canGroup }, - UiDropdownItem(graphText("leave_group"), onClick = onLeave).takeIf { grouped }, - UiDropdownItem(graphText("ungroup"), icon = UngroupIcon, shortcut = HollowKeys.UNGROUP.shortcut, onClick = onUngroup).takeIf { grouped }, - arrange, -) - -/** What the menu of a group offers: opening or collapsing it, taking it apart, lining its nodes up, removing it with its nodes. */ -internal fun groupItems( - group: ShaderGraphGroup, - onToggle: () -> Unit, - onUngroup: () -> Unit, - onDelete: () -> Unit, - arrange: UiDropdownItem?, -): List = listOfNotNull( - UiDropdownItem(graphText(if (group.collapsed) "expand_group" else "collapse_group"), onClick = onToggle), - UiDropdownItem(graphText("ungroup"), icon = UngroupIcon, shortcut = HollowKeys.UNGROUP.shortcut, onClick = onUngroup), - arrange, - UiDropdownItem(graphText("delete_group_nodes"), separatorBefore = true, onClick = onDelete), -) - -private const val RerouteIcon = "hollowengine:textures/gui/icons/actions/reroute.svg" -private const val GroupIcon = "hollowengine:textures/gui/icons/actions/group.svg" -private const val UngroupIcon = "hollowengine:textures/gui/icons/actions/ungroup.svg" - /** The values of a choice, each marked when it is the current one. */ internal fun choiceItems(option: ShaderOptionSpec, current: String, onPick: (String) -> Unit): List = option.values.map { value -> diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeHeader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeHeader.kt deleted file mode 100644 index 8a368d17e..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeHeader.kt +++ /dev/null @@ -1,61 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph - -import androidx.compose.runtime.Composable -import org.lwjgl.glfw.GLFW -import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphNode -import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeType -import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover - -/** - * The title bar in the color of the category: its icon, the name, and the arrow that collapses the node. - */ -@Composable -internal fun NodeHeader( - node: ShaderGraphNode, - kind: ShaderNodeType, - width: Float, - problem: String?, - actions: ShaderNodeActions, -) { - val bar = ShaderNodeLayout.COLLAPSED - Row( - tags = listOf("sg-node-head"), - modifier = Modifier.position(0.px, 0.px).size(width.px, bar.px) - .alignItems(vertical = UiAlign.CENTER) - .let { if (problem != null) it.tooltipOnHover(problem) else it }, - ) { - Image(kind.displayIcon(), tags = listOf("sg-node-icon")) - Text(kind.title(), tags = listOf("sg-node-title"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f)) - Box( - id = "sg-collapse-${node.id}", - tags = listOf("sg-node-toggle"), - modifier = Modifier.input(hoverable = true, clickable = true).onPress { event -> - if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) actions.toggleCollapsed(node.id) - event.consume() - }, - ) { - Image(ArrowIcon, tags = listOf("sg-node-arrow")) - } - } - if (node.collapsed) return - Box( - tags = listOf("sg-node-head-flat"), - modifier = Modifier.position(0.px, (bar - SquaredStrip).px).size(width.px, SquaredStrip.px).inputTransparent(), - ) - listOf(0f, width - 1f).forEach { x -> - Box( - tags = listOf("sg-node-head-edge"), - modifier = Modifier.position(x.px, (bar - SquaredStrip).px).size(1.px, SquaredStrip.px).inputTransparent(), - ) - } - Box( - tags = listOf("sg-node-divider"), - modifier = Modifier.position(0.px, bar.px).size(width.px, 1.px).inputTransparent(), - ) -} - -private const val ArrowIcon = "hollowengine:textures/gui/icons/graph/arrow.svg" - -/** How tall the strip is that squares off the bottom corners of the title bar of an open node. */ -private const val SquaredStrip = 8f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeView.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeView.kt index 04194e77e..43523d6da 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeView.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeView.kt @@ -1,34 +1,18 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraph -import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphTypes -import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderType import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNode -import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNodeGesture -import ru.hollowhorizon.hollowengine.client.ui.graph.GraphViewState +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphEditorController +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNodeFrame +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNodeHeader +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphPinView internal const val ShaderGraphCanvasId = "shader-graph-canvas" -/** - * What a node and its pins do when pressed and dragged. A group, by its title bar or as the node it - * collapses into, is pressed on its own and then dragged and released like the nodes it holds. - */ +/** What the fields of a node do, and its arrow. */ internal interface ShaderNodeActions : ShaderFieldActions { - fun pressNode(node: String, gesture: GraphNodeGesture) - fun dragNode(node: String, gesture: GraphNodeGesture) - fun releaseNode(node: String) - fun nodeMenu(node: String, screenX: Float, screenY: Float) fun toggleCollapsed(node: String) - fun pressPin(pin: ShaderPin, screenX: Float, screenY: Float) - fun dragPin(canvasX: Float, canvasY: Float, screenX: Float, screenY: Float) - fun releasePin() - fun pressGroup(group: String, gesture: GraphNodeGesture) - fun groupMenu(group: String, screenX: Float, screenY: Float) - fun toggleGroup(group: String) } /** @@ -39,55 +23,42 @@ internal interface ShaderNodeActions : ShaderFieldActions { internal fun ShaderNodeView( graph: ShaderGraph, box: ShaderNodeBox, - types: ShaderGraphTypes, - view: GraphViewState, - selected: Boolean, + model: ShaderGraphEditorModel, + controller: GraphEditorController, problem: String?, previews: ShaderGraphPreviews, actions: ShaderNodeActions, ) { val node = box.node val kind = box.kind - GraphNode( - id = "sg-node-${node.id}", - canvasId = ShaderGraphCanvasId, - view = view, - x = node.x, - y = node.y, - width = box.width, - height = box.height, - selected = selected, - tags = listOfNotNull( - "sg-node", - "sg-${kind.category.name.lowercase()}", + GraphNodeFrame( + controller, node.id, box.rect, + listOfNotNull( + "gn-node", + "gn-${kind.category.name.lowercase()}", "collapsed".takeIf { node.collapsed }, "error".takeIf { problem != null }, ), - onPress = { actions.pressNode(node.id, it) }, - onDrag = { actions.dragNode(node.id, it) }, - onRelease = { actions.releaseNode(node.id) }, - onContextMenu = { x, y -> actions.nodeMenu(node.id, x, y) }, ) { - Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { - NodeHeader(node, kind, box.width, problem, actions) - box.rows.forEach { row -> NodeRow(graph, box, row, types, actions) } - if (box.preview > 0f) { - Box( - tags = listOf("sg-node-preview"), - modifier = Modifier.position(ShaderNodeLayout.INSET.px, box.previewTop.px) - .size(box.preview.px, box.previewHeight.px) - .drawBehind(key = node.id) { - drawTexture(bounds, { previews.texture(node.id) }, flipY = true, radius = PreviewRadius) - }, - ) - } - if (!node.collapsed) box.pins.forEach { pin -> - val type = if (pin.output) types.output(pin.node, pin.name) else types.input(pin.node, pin.name) - val connected = graph.links.any { - if (pin.output) it.from == pin.node && it.output == pin.name else it.to == pin.node && it.input == pin.name - } - ShaderPinView(pin, node.x, node.y, type, connected, actions) + GraphNodeHeader(node.id, kind.title(), kind.displayIcon(), box.width, node.collapsed, problem) { + actions.toggleCollapsed(node.id) + } + box.rows.forEach { row -> NodeRow(graph, box, row, actions) } + if (box.preview > 0f) { + Box( + tags = listOf("gn-node-preview"), + modifier = Modifier.position(ShaderNodeLayout.INSET.px, box.previewTop.px) + .size(box.preview.px, box.previewHeight.px) + .drawBehind(key = node.id) { + drawTexture(bounds, { previews.texture(node.id) }, flipY = true, radius = PreviewRadius) + }, + ) + } + if (!node.collapsed) box.pins.forEach { pin -> + val connected = graph.links.any { + if (pin.output) it.from == pin.node && it.output == pin.name else it.to == pin.node && it.input == pin.name } + GraphPinView(controller, pin, node.x, node.y, connected, listOfNotNull(model.typeOf(pin)?.tag())) } } } @@ -97,12 +68,11 @@ private fun NodeRow( graph: ShaderGraph, box: ShaderNodeBox, row: ShaderNodeRow, - types: ShaderGraphTypes, actions: ShaderNodeActions, ) { val node = box.node Row( - tags = listOf("sg-node-row"), + tags = listOf("gn-node-row"), modifier = Modifier.position(0.px, row.top.px).size(box.width.px, row.height.px) .padding(ShaderNodeLayout.INSET.px, 0.px) .alignItems(vertical = UiAlign.CENTER), @@ -110,13 +80,13 @@ private fun NodeRow( when (row) { is ShaderNodeRow.Output -> { Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) - Text(row.name, tags = listOf("sg-pin-label", "output")) + Text(row.name, tags = listOf("gn-pin-label", "output")) } is ShaderNodeRow.Input -> { - Text(row.pin.name, tags = listOf("sg-pin-label")) + Text(row.pin.name, tags = listOf("gn-pin-label")) val reads = row.pin.fallback?.takeIf { graph.linkInto(node.id, row.pin.name) == null } - if (reads != null) Text(reads.name.lowercase(), tags = listOf("sg-pin-hint")) + if (reads != null) Text(reads.name.lowercase(), tags = listOf("gn-pin-hint")) } is ShaderNodeRow.Value -> { @@ -131,41 +101,5 @@ private fun NodeRow( } } -/** A pin on the edge of its node, in the color of what it carries, filled once something is linked. */ -@Composable -internal fun ShaderPinView( - pin: ShaderPin, - nodeX: Float, - nodeY: Float, - type: ShaderType?, - connected: Boolean, - actions: ShaderNodeActions, -) { - val half = ShaderNodeLayout.PIN / 2f - val held = remember { booleanArrayOf(false) } - fun canvas(event: UiEvent) = event.ancestorLocalPositions[ShaderGraphCanvasId] - Box( - id = "sg-pin-${pin.node}-${if (pin.output) "out" else "in"}-${pin.name}", - tags = listOfNotNull("sg-pin", type?.tag(), "connected".takeIf { connected }), - modifier = Modifier.position((pin.x - nodeX - half).px, (pin.y - nodeY - half).px) - .size(ShaderNodeLayout.PIN.px, ShaderNodeLayout.PIN.px) - .input(hoverable = true, clickable = true, draggable = true).onPress { event -> - if (event.button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return@onPress - held[0] = true - actions.pressPin(pin, event.x, event.y) - event.consume() - }.onDrag { event -> - if (!held[0]) return@onDrag - canvas(event)?.let { actions.dragPin(it.x, it.y, event.x, event.y) } - event.consume() - }.onRelease { event -> - if (!held[0]) return@onRelease - held[0] = false - actions.releasePin() - event.consume() - }, - ) -} - /** The corners of a preview, the same as of the fields on a node; a frame of a post effect fills it to them. */ private const val PreviewRadius = 5f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphOrganize.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphOrganize.kt deleted file mode 100644 index a526fbde7..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphOrganize.kt +++ /dev/null @@ -1,72 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph - -import ru.hollowhorizon.hollowengine.client.shadergraph.* -import ru.hollowhorizon.hollowengine.client.ui.graph.GraphArrange -import ru.hollowhorizon.hollowengine.client.ui.graph.GraphRect -import ru.hollowhorizon.hollowengine.client.ui.graph.graphArrangeItem -import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem - -/** Puts [nodes] in a new group, which is then what is selected. */ -internal fun HollowIdeShaderGraphDocument.groupNodes(nodes: Set): ShaderGraphSelection? { - if (nodes.isEmpty()) return null - val (next, id) = graph.withGroup(nodes, graphText("group")) - if (id.isEmpty()) return null - edit { next } - return ShaderGraphSelection(nodes, group = id) -} - -/** Takes apart the group of [selection], or every group its nodes are in; the nodes stay selected. */ -internal fun HollowIdeShaderGraphDocument.ungroup(selection: ShaderGraphSelection): ShaderGraphSelection? { - val groups = selection.group?.let(::listOf) ?: selection.nodes.mapNotNull { graph.groupOf(it)?.id }.distinct() - if (groups.isEmpty()) return null - edit { graph -> groups.fold(graph) { current, id -> current.withoutGroup(id) } } - return ShaderGraphSelection(selection.nodes) -} - -/** - * Nodes that were in no group and have been dragged into the frame of one join it, the way they would - * be put in a box. A frame fits itself around its nodes, so a node leaves it by the node's menu instead. - */ -internal fun joinFrame( - document: HollowIdeShaderGraphDocument, - scene: ShaderGraphScene, - boxes: Map, - origins: Map>, -) { - val graph = document.graph - val loose = origins.keys.filter { id -> - val node = graph.node(id) ?: return@filter false - graph.groupOf(id) == null && origins.getValue(id) != (node.x to node.y) - } - if (loose.isEmpty()) return - val target = scene.frames.entries.firstOrNull { (_, frame) -> - loose.all { id -> - val node = graph.node(id) ?: return@all false - val box = boxes[id] ?: return@all false - frame.contains(node.x + box.width / 2f, node.y + box.height / 2f) - } - }?.key ?: return - document.edit { it.withMembership(loose.toSet(), target) } -} - -/** A reroute put into the link at [index], centered on ([x], [y]) of the graph; the reroute is selected. */ -internal fun HollowIdeShaderGraphDocument.addReroute(index: Int, x: Float, y: Float): ShaderGraphSelection { - val id = graph.freeNodeId(ShaderNodeLibrary.REROUTE) - val half = ShaderNodeLayout.REROUTE / 2f - edit { it.withReroute(index, id, x - half, y - half) } - return ShaderGraphSelection.of(id) -} - -/** Lining up and spacing out those of [ids] that are on the canvas, as boxes of it. */ -internal fun HollowIdeShaderGraphDocument.arrangeItem(boxes: Map, ids: Collection): UiDropdownItem? { - val selected: Map = ids.mapNotNull { id -> boxes[id]?.let { id to it.rect } }.toMap() - fun move(to: Map>) { - if (to.isNotEmpty()) edit { it.withNodesAt(to) } - } - return graphArrangeItem( - selected.size, - onAlign = { alignment -> move(GraphArrange.align(selected, alignment)) }, - onDistribute = { horizontal -> move(GraphArrange.distribute(selected, horizontal)) }, - ) -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphScene.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphScene.kt index 37d41b89a..a4a426f8c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphScene.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphScene.kt @@ -2,8 +2,11 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraph import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphGroup +import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.ui.graph.GROUP_HEADER import ru.hollowhorizon.hollowengine.client.ui.graph.GROUP_PADDING +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphCardView +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphPin import ru.hollowhorizon.hollowengine.client.ui.graph.GraphRect /** @@ -13,10 +16,10 @@ import ru.hollowhorizon.hollowengine.client.ui.graph.GraphRect internal class ShaderGroupCard( val group: ShaderGraphGroup, val rect: GraphRect, - val inputs: List, - val outputs: List, + val inputs: List, + val outputs: List, ) { - val pins: List get() = inputs + outputs + val pins: List get() = inputs + outputs } /** What is displayed on the canvas from the graph, taking its groups into account */ @@ -24,7 +27,7 @@ internal class ShaderGraphScene( val boxes: Map, val frames: Map, val cards: Map, - val pins: List, + val pins: List, ) { companion object { fun of(graph: ShaderGraph, all: Map): ShaderGraphScene { @@ -60,35 +63,8 @@ internal class ShaderGraphScene( graph.links.filter { it.to in members && it.from !in members }.map { it.to to it.input }.distinct() val leaving = graph.links.filter { it.from in members && it.to !in members }.map { it.from to it.output }.distinct() - val rows = entering.size + leaving.size - val height = - ShaderNodeLayout.HEADER + if (rows == 0) 0f else ShaderNodeLayout.PADDING * 2 + rows * ShaderNodeLayout.ROW - val rect = GraphRect(around.x, around.y, ShaderNodeLayout.WIDTH, height) - - fun rowY(index: Int) = - rect.y + ShaderNodeLayout.HEADER + ShaderNodeLayout.PADDING + ShaderNodeLayout.ROW * (index + 0.5f) - return ShaderGroupCard( - group = group, - rect = rect, - outputs = leaving.mapIndexed { index, (node, pin) -> - ShaderPin( - node, - pin, - true, - rect.x + rect.width, - rowY(index) - ) - }, - inputs = entering.mapIndexed { index, (node, pin) -> - ShaderPin( - node, - pin, - false, - rect.x, - rowY(leaving.size + index) - ) - }, - ) + val card = GraphCardView.of(group.id, "", UiColor(1f, 1f, 1f), around, entering, leaving) + return ShaderGroupCard(group, card.rect, card.inputs, card.outputs) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphSceneViews.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphSceneViews.kt index 17e15b244..02ac6682e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphSceneViews.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphSceneViews.kt @@ -2,9 +2,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph import androidx.compose.runtime.Composable import androidx.compose.runtime.key -import androidx.compose.runtime.remember import ru.hollowhorizon.hollowengine.client.shadergraph.* -import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.graph.* /** @@ -14,50 +12,29 @@ import ru.hollowhorizon.hollowengine.client.ui.graph.* @Composable internal fun ShaderGraphSceneView( graph: ShaderGraph, - scene: ShaderGraphScene, + model: ShaderGraphEditorModel, + controller: GraphEditorController, types: ShaderGraphTypes, - view: GraphViewState, - selection: ShaderGraphSelection, problems: Map>, previews: ShaderGraphPreviews, actions: ShaderNodeActions, ) { - scene.frames.forEach { (id, rect) -> - val group = graph.group(id) ?: return@forEach - key("frame-$id") { - GraphGroupHeader( - id = "sg-frame-$id", - canvasId = ShaderGraphCanvasId, - view = view, - frame = rect, - title = group.title.ifBlank { graphText("group") }, - color = GraphGroupColors.of(group.color), - selected = selection.group == id, - onPress = { actions.pressGroup(id, it) }, - onDrag = { actions.dragNode(id, it) }, - onRelease = { actions.releaseNode(id) }, - onContextMenu = { x, y -> actions.groupMenu(id, x, y) }, - onToggle = { actions.toggleGroup(id) }, - ) - } - } - scene.boxes.values.sortedBy { it.node.id in selection }.forEach { box -> + val scene = model.nodes + GraphFrameHeaders(controller) + scene.boxes.values.forEach { box -> key(box.node.id) { - if (box.isReroute) { - ShaderRerouteView( - box = box, - type = types.output(box.node.id, ShaderNodeLibrary.REROUTE_OUTPUT), - view = view, - selected = box.node.id in selection, - actions = actions, + when { + box.node.id !in controller.shown -> Unit + box.isReroute -> GraphRerouteView( + controller, box.node.id, box.rect, box.pins.first { it.output }, + listOfNotNull(types.output(box.node.id, ShaderNodeLibrary.REROUTE_OUTPUT)?.tag()), ) - } else { - ShaderNodeView( + + else -> ShaderNodeView( graph = graph, box = box, - types = types, - view = view, - selected = box.node.id in selection, + model = model, + controller = controller, problem = problems[box.node.id]?.joinToString("\n") { problemText(it) }, previews = previews, actions = actions, @@ -65,128 +42,14 @@ internal fun ShaderGraphSceneView( } } } - scene.cards.values.forEach { card -> - key("card-${card.group.id}") { - ShaderGroupCardView(graph, card, types, view, selected = selection.group == card.group.id, actions = actions) - } - } -} - -/** - * A reroute: a dot in the color of what passes through it. A drag moves it; Alt and a drag pull a new - * link out of it, since a dot has no room for a pin of its own. - */ -@Composable -internal fun ShaderRerouteView( - box: ShaderNodeBox, - type: ShaderType?, - view: GraphViewState, - selected: Boolean, - actions: ShaderNodeActions, -) { - val node = box.node - val linking = remember { booleanArrayOf(false) } - GraphNode( - id = "sg-node-${node.id}", - canvasId = ShaderGraphCanvasId, - view = view, - x = node.x, - y = node.y, - width = box.width, - height = box.height, - selected = selected, - tags = listOfNotNull("sg-reroute", type?.tag()), - onPress = { gesture -> - linking[0] = gesture.alt - val output = box.pins.first { it.output } - if (gesture.alt) actions.pressPin(output, gesture.screenX, gesture.screenY) else actions.pressNode(node.id, gesture) - }, - onDrag = { gesture -> - if (linking[0]) actions.dragPin(gesture.canvasX, gesture.canvasY, gesture.screenX, gesture.screenY) - else actions.dragNode(node.id, gesture) - }, - onRelease = { - if (linking[0]) actions.releasePin() else actions.releaseNode(node.id) - linking[0] = false - }, - onContextMenu = { x, y -> actions.nodeMenu(node.id, x, y) }, - ) {} -} - -/** - * A collapsed group as one node: title bar in group's color with arrow that opens it - * again, and a row per pin surrounding links reach, named after pin and node it belongs to. - */ -@Composable -internal fun ShaderGroupCardView( - graph: ShaderGraph, - card: ShaderGroupCard, - types: ShaderGraphTypes, - view: GraphViewState, - selected: Boolean, - actions: ShaderNodeActions, -) { - val group = card.group - val rect = card.rect - GraphNode( - id = "sg-group-${group.id}", - canvasId = ShaderGraphCanvasId, - view = view, - x = rect.x, - y = rect.y, - width = rect.width, - height = rect.height, - selected = selected, - tags = listOf("sg-node", "sg-group-card"), - onPress = { actions.pressGroup(group.id, it) }, - onDrag = { actions.dragNode(group.id, it) }, - onRelease = { actions.releaseNode(group.id) }, - onContextMenu = { x, y -> actions.groupMenu(group.id, x, y) }, - ) { - Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { - Row( - tags = listOf("sg-node-head", "sg-group-head"), - modifier = Modifier.position(0.px, 0.px).size(rect.width.px, ShaderNodeLayout.COLLAPSED.px) - .background(GraphGroupColors.of(group.color)).alignItems(vertical = UiAlign.CENTER), - ) { - Image(GroupIcon, tags = listOf("sg-node-icon")) - Text( - group.title.ifBlank { graphText("group") }, - tags = listOf("sg-node-title"), - modifier = Modifier.size(0.px, UiLength.Auto).grow(1f), - ) - GraphGroupToggle("sg-group-toggle-${group.id}", collapsed = true) { actions.toggleGroup(group.id) } - } - card.inputs.forEach { pin -> CardRow(graph, pin, rect.y, rect.width) } - card.outputs.forEach { pin -> CardRow(graph, pin, rect.y, rect.width) } - card.pins.forEach { pin -> - val type = if (pin.output) types.output(pin.node, pin.name) else types.input(pin.node, pin.name) - ShaderPinView(pin, rect.x, rect.y, type, connected = true, actions) - } - } - } -} - -/** The name of a pin of a collapsed group, on its side of the node, with the node it belongs to after it. */ -@Composable -private fun CardRow(graph: ShaderGraph, pin: ShaderPin, y: Float, width: Float) { - val owner = graph.node(pin.node)?.let { ShaderNodeTypes.of(it.type) }?.title() ?: pin.node - val half = ShaderNodeLayout.ROW / 2f - Row( - tags = listOf("sg-node-row"), - modifier = Modifier.position(0.px, (pin.y - y - half).px).size(width.px, ShaderNodeLayout.ROW.px) - .padding(ShaderNodeLayout.INSET.px, 0.px).alignItems(vertical = UiAlign.CENTER).inputTransparent(), - ) { - // The pin name sits next to its pin, the node it belongs to on the inner side. - if (pin.output) { - Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) - Text(owner, tags = listOf("sg-pin-hint")) - Text(pin.name, tags = listOf("sg-pin-label", "output")) - } else { - Text(pin.name, tags = listOf("sg-pin-label")) - Text(owner, tags = listOf("sg-pin-hint")) + model.scene.cards.forEach { card -> + key("card-${card.id}") { + GraphGroupCard( + controller, card, + pinName = { it.name }, + ownerName = { pin -> graph.node(pin.node)?.let { ShaderNodeTypes.of(it.type) }?.title() ?: pin.node }, + pinTags = { pin -> listOfNotNull(model.typeOf(pin)?.tag()) }, + ) } } } - -private const val GroupIcon = "hollowengine:textures/gui/icons/actions/group.svg" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphState.kt index 5d456999c..515a3010c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphState.kt @@ -4,44 +4,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderDiagnostic -import ru.hollowhorizon.hollowengine.client.ui.graph.GraphViewState - -/** - * What is selected: some nodes, or one link, by its position among the links. A group taken by its - * title bar selects its nodes and names itself in [group], which is what inspector shows. - */ -internal data class ShaderGraphSelection(val nodes: Set = emptySet(), val link: Int? = null, val group: String? = null) { - val single: String? get() = nodes.singleOrNull() - - operator fun contains(node: String): Boolean = node in nodes - - companion object { - val None = ShaderGraphSelection() - - fun of(node: String) = ShaderGraphSelection(setOf(node)) - fun ofLink(index: Int) = ShaderGraphSelection(link = index) - } -} +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphEditorState /** What editor of one file keeps while file stays open: view and selection. */ internal class ShaderGraphEditorState { - val view = GraphViewState() - var selection by mutableStateOf(ShaderGraphSelection.None) + val editor = GraphEditorState() /** What is wrong with the graph as it last compiled, for the inspector to list. */ var diagnostics by mutableStateOf(emptyList()) } - -/** - * A link being drawn from a pin toward pointer: where pointer is on canvas and screen, - * and where menu opens if it is dropped on nothing. - */ -internal data class PendingLink( - val node: String, - val pin: String, - val fromOutput: Boolean, - val x: Float, - val y: Float, - val screenX: Float, - val screenY: Float, -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphValueFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphValueFields.kt index ea91cbf83..0114c0a95 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphValueFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphValueFields.kt @@ -30,17 +30,17 @@ internal fun ValueFields( actions.setValue(node.id, pin.name, shown.toMutableList().also { it[component] = value }) if (components == 1 && !pin.color) { - GraphNumber("sg-field-${node.id}-${pin.name}-0", shown[0], actions, label = pin.name) { set(0, it) } + GraphNumber("gn-field-${node.id}-${pin.name}-0", shown[0], actions, label = pin.name) { set(0, it) } return } - Text(pin.name, tags = listOf("sg-pin-label"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f)) + Text(pin.name, tags = listOf("gn-pin-label"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f)) if (pin.color && components >= 3) { - ColorSwatch("sg-color-${node.id}-${pin.name}", shown, actions) { actions.setValue(node.id, pin.name, it) } + ColorSwatch("gn-color-${node.id}-${pin.name}", shown, actions) { actions.setValue(node.id, pin.name, it) } return } - Row(tags = listOf("sg-fields"), modifier = Modifier.size(FieldsWidth.px, UiLength.Auto)) { + Row(tags = listOf("gn-fields"), modifier = Modifier.size(FieldsWidth.px, UiLength.Auto)) { shown.forEachIndexed { component, value -> - GraphNumber("sg-field-${node.id}-${pin.name}-$component", value, actions) { set(component, it) } + GraphNumber("gn-field-${node.id}-${pin.name}-$component", value, actions) { set(component, it) } } } } @@ -63,7 +63,7 @@ internal fun GraphNumber( id = id, label = label, format = { formatValue(it.toFloat()) }, - tags = listOf("sg-field"), + tags = listOf("gn-field"), modifier = Modifier.size(0.px, FieldHeight.px).grow(1f), onGestureStart = { if (gestures) actions.beginGesture() }, onGestureEnd = { if (gestures) actions.endGesture() }, @@ -80,7 +80,7 @@ private fun ColorSwatch(id: String, values: List, actions: ShaderFieldAct val color = UiColor(values[0], values[1], values[2], values.getOrElse(3) { 1f }) Box( id = id, - tags = listOf("sg-swatch"), + tags = listOf("gn-swatch"), modifier = Modifier.size(FieldsWidth.px, FieldHeight.px) .drawBehind(key = color) { val inner = UiRect(bounds.x + 1f, bounds.y + 1f, bounds.width - 2f, bounds.height - 2f) @@ -98,13 +98,13 @@ private fun ColorSwatch(id: String, values: List, actions: ShaderFieldAct Popup( anchorBounds = open, id = "$id-popup", - tags = listOf("sg-color-popup"), + tags = listOf("gn-color-popup"), onDismiss = { anchor = null actions.endGesture() }, ) { - Column(tags = listOf("sg-color-popup-body")) { + Column(tags = listOf("gn-color-popup-body")) { ColorPicker( value = color.clamped(), showAlpha = values.size == 4, @@ -114,7 +114,7 @@ private fun ColorSwatch(id: String, values: List, actions: ShaderFieldAct ) }, ) - Row(tags = listOf("sg-fields"), modifier = Modifier.size(ColorPickerWidth.px, UiLength.Auto)) { + Row(tags = listOf("gn-fields"), modifier = Modifier.size(ColorPickerWidth.px, UiLength.Auto)) { values.forEachIndexed { channel, value -> GraphNumber("$id-channel-$channel", value, actions, gestures = false) { typed -> onChange(values.toMutableList().also { it[channel] = typed }) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/AudioFilePanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/AudioFilePanel.kt index 95a4a84de..fb35dbb8a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/AudioFilePanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/AudioFilePanel.kt @@ -181,7 +181,7 @@ private class AudioFileState(path: String) { playing = true Minecraft.getInstance().execute { if (existing != null && existing.settings.repeat == settings.repeat) existing.resume() - else sound = ClientSounds.play(source, settings, id = id, start = from) + else sound = ClientSounds.play(source, settings, id = id, start = from, pauseWithGame = false) } } @@ -209,7 +209,7 @@ private class AudioFileState(path: String) { return } Minecraft.getInstance().execute { - sound = ClientSounds.play(source, playingNow.settings, id = id, start = seconds) + sound = ClientSounds.play(source, playingNow.settings, id = id, start = seconds, pauseWithGame = false) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/SoundsPreview.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/SoundsPreview.kt index 6fd42c078..d97a1b4ba 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/SoundsPreview.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/sounds/SoundsPreview.kt @@ -25,8 +25,7 @@ private const val SoundIcon = "hollowengine:textures/gui/icons/file_sound.png" private fun key(name: String) = "hollowengine.gui.sounds_editor.$name" -internal fun String.isSoundsFile(): Boolean = - substringAfterLast('/').equals(SoundsFileName, ignoreCase = true) +internal fun String.isSoundsFile(): Boolean = substringAfterLast('/').equals(SoundsFileName, ignoreCase = true) /** What the editor keeps while its tab is switched away. */ private class SoundsPreviewState { @@ -56,8 +55,7 @@ internal fun SoundsPreview(context: HollowIdePreviewContext) { if (availableSounds.isEmpty()) null else UiCompletionContributor { completion -> val prefix = completion.text.take(completion.caret.coerceIn(0, completion.text.length)) - availableSounds - .filter { prefix.isEmpty() || it.startsWith(prefix, ignoreCase = true) } + availableSounds.filter { prefix.isEmpty() || it.startsWith(prefix, ignoreCase = true) } .map { UiTextCompletion(label = it, insertText = it, icon = SoundIcon) } } } @@ -77,8 +75,7 @@ internal fun SoundsPreview(context: HollowIdePreviewContext) { Column( tags = listOf("ide-file-panel", "sounds-editor-root"), - modifier = Modifier.style(InspectorStylesheet).style("hollowengine:ui/styles/sounds-editor.hss") - .focusScope(), + modifier = Modifier.style(InspectorStylesheet).style("hollowengine:ui/styles/sounds-editor.hss").focusScope(), ) { Row(tags = listOf("sounds-editor-toolbar"), modifier = Modifier.alignItems(vertical = UiAlign.CENTER)) { Text(key("title").lang, tags = listOf("sounds-editor-title"), modifier = Modifier.grow(1f)) @@ -107,7 +104,8 @@ internal fun SoundsPreview(context: HollowIdePreviewContext) { state.selected = model.addEvent() markChanged() } - val filtered = model.events.filter { state.query.isBlank() || it.name.contains(state.query, ignoreCase = true) } + val filtered = + model.events.filter { state.query.isBlank() || it.name.contains(state.query, ignoreCase = true) } if (filtered.isEmpty()) { Text(key("no_events").lang, tags = listOf("sounds-editor-empty")) } @@ -135,7 +133,8 @@ internal fun SoundsPreview(context: HollowIdePreviewContext) { Column( tags = listOf("sounds-editor-detail"), - modifier = Modifier.size(0.px, 100.percent).grow(1f).scrollable(horizontal = false, state = state.detailScroll), + modifier = Modifier.size(0.px, 100.percent).grow(1f) + .scrollable(horizontal = false, state = state.detailScroll), ) { val event = current if (event == null) { @@ -166,11 +165,8 @@ private fun EventRow( } Row( tags = tags, - modifier = Modifier.size(100.percent, 24.px) - .input(hoverable = true, clickable = true) - .cursor(UiCursorShape.HAND) - .alignItems(vertical = UiAlign.CENTER) - .onClick { event2 -> + modifier = Modifier.size(100.percent, 24.px).input(hoverable = true, clickable = true) + .cursor(UiCursorShape.HAND).alignItems(vertical = UiAlign.CENTER).onClick { event2 -> if (event2.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onSelect() event2.consume() }, @@ -209,7 +205,10 @@ private fun EventDetail( modifier = Modifier.size(100.percent, 22.px), ) } - Row(tags = listOf("sounds-editor-toggle"), modifier = Modifier.size(100.percent, 20.px).alignItems(vertical = UiAlign.CENTER)) { + Row( + tags = listOf("sounds-editor-toggle"), + modifier = Modifier.size(100.percent, 20.px).alignItems(vertical = UiAlign.CENTER) + ) { Text(key("replace").lang, tags = listOf("sounds-editor-label"), modifier = Modifier.grow(1f)) Checkbox( checked = event.replace, @@ -218,8 +217,15 @@ private fun EventDetail( ) } - Row(tags = listOf("sounds-editor-section"), modifier = Modifier.size(100.percent, 24.px).alignItems(vertical = UiAlign.CENTER)) { - Text(key("sounds").lang.format(event.sounds.size), tags = listOf("sounds-editor-section-title"), modifier = Modifier.grow(1f)) + Row( + tags = listOf("sounds-editor-section"), + modifier = Modifier.size(100.percent, 24.px).alignItems(vertical = UiAlign.CENTER) + ) { + Text( + key("sounds").lang.format(event.sounds.size), + tags = listOf("sounds-editor-section-title"), + modifier = Modifier.grow(1f) + ) SoundsButton(key("add_sound"), AddIcon, modifier = Modifier.size(112.px, 22.px)) { event.sounds += SoundEntry() onChanged() @@ -251,19 +257,23 @@ private fun SoundCard( value = sound.name, onChange = { sound.name = sanitizeSoundName(it); onChanged() }, placeholder = "namespace:path", - completionContributor = soundCompletions, + insight = UiCodeInsight(completions = soundCompletions), tags = listOf("sounds-editor-field"), modifier = Modifier.size(0.px, 22.px).grow(1f), ) SoundsIconButton(RemoveIcon, tags = listOf("sounds-editor-sound-delete"), onClick = onDelete) } - NumberRow(key("volume").lang, sound.volume, min = 0f, max = 1f, slider = true) { sound.volume = it; onChanged() } + NumberRow(key("volume").lang, sound.volume, min = 0f, max = 1f, slider = true) { + sound.volume = it; onChanged() + } NumberRow(key("pitch").lang, sound.pitch, min = 0.5f, max = 2f, slider = true) { sound.pitch = it; onChanged() } Row(modifier = Modifier.size(100.percent, 22.px).gap(6.px).alignItems(vertical = UiAlign.CENTER)) { NumberRow(key("weight").lang, sound.weight, min = 1) { sound.weight = it; onChanged() } - NumberRow(key("attenuation").lang, sound.attenuationDistance, min = 0) { sound.attenuationDistance = it; onChanged() } + NumberRow(key("attenuation").lang, sound.attenuationDistance, min = 0) { + sound.attenuationDistance = it; onChanged() + } } Row(modifier = Modifier.size(100.percent, 20.px).gap(10.px).alignItems(vertical = UiAlign.CENTER)) { @@ -303,20 +313,21 @@ private fun SoundsButton(labelKey: String, icon: String, modifier: Modifier = Mo Button(labelKey.lang, icon = icon, modifier = modifier, onClick = onClick) @Composable -private fun SoundsIconButton(icon: String, tags: List, onClick: () -> Unit) = - IconButton(icon, tooltip = null, tags = listOf("inline", "danger") + tags, modifier = Modifier.size(16.px, 16.px), onClick = onClick) +private fun SoundsIconButton(icon: String, tags: List, onClick: () -> Unit) = IconButton( + icon, + tooltip = null, + tags = listOf("inline", "danger") + tags, + modifier = Modifier.size(16.px, 16.px), + onClick = onClick +) /** Enumerates audio files under `/sounds/` as `namespace:path` ids for name suggestions. */ private fun scanAvailableSounds(namespaceDir: String, namespace: String): List { if (namespaceDir.isEmpty()) return emptyList() val soundsRoot = runCatching { "$namespaceDir/sounds".fromReadablePath() }.getOrNull() ?: return emptyList() if (!soundsRoot.isDirectory) return emptyList() - return soundsRoot.walkTopDown() - .filter { it.isFile && it.extension.lowercase() in SoundDecoders.extensions } - .map { relativeSoundId(soundsRoot, it, namespace) } - .distinct() - .sorted() - .toList() + return soundsRoot.walkTopDown().filter { it.isFile && it.extension.lowercase() in SoundDecoders.extensions } + .map { relativeSoundId(soundsRoot, it, namespace) }.distinct().sorted().toList() } private fun relativeSoundId(root: File, file: File, namespace: String): String { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt index 5fa66a30b..7b0a5c01d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt @@ -11,6 +11,7 @@ import ru.hollowhorizon.hollowengine.client.history.UndoKeys import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoPointerWrap +import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoRotationHint import ru.hollowhorizon.hollowengine.client.ui.ide.GizmoToolbarButtons import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOpenFile import ru.hollowhorizon.hollowengine.client.ui.ide.PublishScene @@ -147,7 +148,12 @@ internal fun VfxEditorPanel(file: HollowIdeOpenFile) { } Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 0.px).grow(1f)) { - Viewport(document, state, selected) + Box( + id = "vfx-viewport-focus-${System.identityHashCode(state)}", + modifier = Modifier.size(100.percent, 100.percent).focusScope(), + ) { + Viewport(document, state, selected) + } Toolbar(state) ParticleCounter(state.preview) } @@ -172,7 +178,7 @@ private fun Toolbar(state: VfxEditorState) { IconButton(ShapeIcon, vfxText("toggle_shape"), iconSize = 12f, active = preview.showShape) { preview.showShape = !preview.showShape } - GizmoToolbarButtons() + GizmoToolbarButtons("vfx-gizmo-${System.identityHashCode(state)}") } } @@ -291,7 +297,7 @@ private fun Viewport(document: HollowIdeVfxDocument, state: VfxEditorState, sele } Box( - id = "vfx-viewport", + id = "vfx-viewport-${System.identityHashCode(state)}", tags = listOf("ide-file-viewport"), mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent).clip().onPlaced { rect -> @@ -374,7 +380,8 @@ private fun Viewport(document: HollowIdeVfxDocument, state: VfxEditorState, sele session.draw(this, target()) }, ) - ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px)) + ViewAxes(state.cameraTurn, Modifier.align(UiAlign.END, UiAlign.START).margin(8.px).focusScope()) + GizmoRotationHint(session, Modifier.align(UiAlign.START, UiAlign.END).margin(8.px)) KeyInsertMenu(state.keyMenu, onPick = { parts -> selected?.let { state.session.insertKeys(it, parts) } }) { state.keyMenu = null } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxFields.kt index be528504c..99285f7d6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxFields.kt @@ -52,10 +52,14 @@ class VfxDrivenValue( * expression one wide field, and a curve, the only one that cannot be typed, shows its shape. */ private enum class VfxValueKind(val key: String, val icon: String) { - CONST("kind_const", "hollowengine:textures/gui/icons/vfx/value_const.svg"), - RANGE("kind_range", "hollowengine:textures/gui/icons/vfx/value_range.svg"), - CURVE("kind_curve", "hollowengine:textures/gui/icons/vfx/value_curve.svg"), - EXPR("kind_expr", "hollowengine:textures/gui/icons/vfx/value_expr.svg"); + CONST("kind_const", "hollowengine:textures/gui/icons/vfx/value_const.svg"), RANGE( + "kind_range", + "hollowengine:textures/gui/icons/vfx/value_range.svg" + ), + CURVE("kind_curve", "hollowengine:textures/gui/icons/vfx/value_curve.svg"), EXPR( + "kind_expr", + "hollowengine:textures/gui/icons/vfx/value_expr.svg" + ); } private fun VfxValue.kind(): VfxValueKind = when (this) { @@ -152,8 +156,7 @@ fun VfxVec3Row( Column(modifier = Modifier.size(100.percent).gap(2.px)) { Row(modifier = Modifier.size(100.percent).alignItems(vertical = UiAlign.CENTER)) { VfxFieldLabel( - label, - hint + label, hint ) } VfxValueRow(" X", value.x, property, channel = 0) { onChange(value.copy(x = it)) } @@ -167,25 +170,13 @@ fun VfxVec3Row( Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { VfxFieldLabel(label, hint, VectorLabelWidth) AxisNumber( - "X", - value.x.value, - property, - driven, - 0 + "X", value.x.value, property, driven, 0 ) { onChange(value.copy(x = VfxValue.of(it))) } AxisNumber( - "Y", - value.y.value, - property, - driven, - 1 + "Y", value.y.value, property, driven, 1 ) { onChange(value.copy(y = VfxValue.of(it))) } AxisNumber( - "Z", - value.z.value, - property, - driven, - 2 + "Z", value.z.value, property, driven, 2 ) { onChange(value.copy(z = VfxValue.of(it))) } if (driven != null) { DrivenMark(property) @@ -287,11 +278,21 @@ private fun DrivenCell(driven: VfxDrivenValue, property: VfxProperty?, channel: @Composable private fun DrivenMark(property: VfxProperty?) { val focus = LocalVfxFieldFocus.current - InspectorIconButton(KeyframeIcon, vfxText("driven_show"), tags = listOf("insp-inline-icon")) { if (property != null) focus(property) } + InspectorIconButton( + KeyframeIcon, + vfxText("driven_show"), + tags = listOf("insp-inline-icon") + ) { if (property != null) focus(property) } } @Composable -private fun NumberCell(value: Float, property: VfxProperty?, whole: Boolean, step: Double? = null, onChange: (Float) -> Unit) { +private fun NumberCell( + value: Float, + property: VfxProperty?, + whole: Boolean, + step: Double? = null, + onChange: (Float) -> Unit, +) { val focus = LocalVfxFieldFocus.current NumberField( value = value.toDouble(), @@ -310,9 +311,11 @@ private fun TextCell(text: String, property: VfxProperty?, onChange: (String) -> value = text, fontSize = 9f, placeholder = vfxText("expression_placeholder"), - completionContributor = VfxExpressionEditing.completions, - syntaxHighlighter = VfxExpressionEditing.highlighter, - diagnostics = diagnostics, + insight = UiCodeInsight( + completions = VfxExpressionEditing.completions, + highlighter = VfxExpressionEditing.highlighter, + diagnostics = diagnostics, + ), tags = listOf("insp-input", "insp-inline-input", "vfx-expression"), modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).onFocus { if (property != null) focus(property) } .then(if (diagnostics.isEmpty()) Modifier.tooltipOnHover(vfxText("expression_hint")) else Modifier), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt index 742f923ef..6d2a67512 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt @@ -262,7 +262,8 @@ class VfxTimelineSession( fun select(nodeId: String?) { selection = nodeId selectedLane = null - if (nodeId?.let(touched::add) == true || onlySelectedState) applyListing() + nodeId?.let(touched::add) + applyListing() } /** Opens [lane], picked in the scene in place of a node, as what is selected now. */ @@ -317,6 +318,7 @@ class VfxTimelineSession( private fun applyListing(group: TrackGroup) { group.children.forEach(::applyListing) val node = group.properties.firstOrNull()?.let(::nodeOf) + group.isSelected = node != null && node == selection val shown = !onlySelectedState || node == selection group.properties.forEach { property -> val keyed = property.curves.any { it.keyframes.isNotEmpty() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt index 49aacafc7..b1f3045a4 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/history/IdeHistories.kt @@ -9,8 +9,8 @@ import ru.hollowhorizon.hollowengine.client.history.UndoHistory import ru.hollowhorizon.hollowengine.client.history.UndoLabel import ru.hollowhorizon.hollowengine.client.history.UndoOwner import ru.hollowhorizon.hollowengine.client.ui.ide.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSessions import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.IdeTimelines +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorSelection import ru.hollowhorizon.hollowengine.client.utils.IconHelper import ru.hollowhorizon.hollowengine.client.utils.lang @@ -33,7 +33,7 @@ internal class IdeHistories( add(HistoryContext(file.id, file.title, IconHelper.forPath(file.path).toString(), history)) } - val timeline = timelineHistory() + val timeline = timelineHistory() ?: return@buildList if (none { it.history === timeline }) add(HistoryContext(TIMELINE, "$LANG.timeline".lang, TIMELINE_ICON, timeline)) } @@ -54,18 +54,21 @@ internal class IdeHistories( val history = when (focused) { null -> null TimelineId -> timelineHistory() - SceneId, InspectorId, GameViewportId -> - IdeScenes.current?.history ?: WorldHistory.history.takeIf { EditorMode.isAvailable() } + InspectorId -> InspectorSelection.current?.history ?: sceneHistory() + SceneId, GameViewportId -> sceneHistory() else -> model.files.values.firstOrNull { it.id == focused }?.let(::historyOf) } ?: return null return contexts.firstOrNull { it.history === history } } + private fun sceneHistory(): UndoHistory? = + IdeScenes.current?.history ?: WorldHistory.history.takeIf { EditorMode.isAvailable() } + private fun historyOf(file: HollowIdeOpenFile): UndoHistory? = (file.document as? UndoOwner)?.history ?: textHistory(file) - private fun timelineHistory(): UndoHistory = - (IdeTimelines.current?.controller ?: CutsceneEditorSessions.default.timeline).history + private fun timelineHistory(): UndoHistory? = + (IdeTimelines.current?.controller ?: IdeTimelines.default?.controller)?.history private companion object { const val LANG = UndoLabel.LANG diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsolePanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsolePanel.kt index d96f52ae7..46da15086 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsolePanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsolePanel.kt @@ -10,6 +10,7 @@ import ru.hollowhorizon.hollowengine.client.ui.docking.LocalDockPanelTitle import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeConsoleFontSize import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeProblems import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeProblemsList +import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinCodeSyntax import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState import ru.hollowhorizon.hollowengine.client.ui.widgets.* import ru.hollowhorizon.hollowengine.client.utils.lang @@ -110,6 +111,12 @@ private fun ConsoleToolbar(console: HollowIdeConsole) { tags = listOf("console-auto-scroll"), modifier = Modifier.tooltipOnHover("$LANG.auto_scroll".lang), ) + ConsoleIconButton( + id = "console-clear", + icon = ClearIcon, + tooltip = "$LANG.clear".lang, + onClick = console::clearLogView, + ) } } @@ -127,8 +134,11 @@ private fun ConsoleLog(console: HollowIdeConsole) { val filter = remember(console.filterText) { ConsoleLogFilter.compile(console.filterText) } val minimumLevel = console.minimumLevel - val visibleEntries = remember(snapshot, minimumLevel, filter) { - snapshot.entries.filter { entry -> entry.level.ordinal <= minimumLevel.ordinal && filter.matches(entry) } + val firstVisibleId = console.firstVisibleLogId + val visibleEntries = remember(snapshot, minimumLevel, filter, firstVisibleId) { + snapshot.entries.filter { entry -> + entry.id >= firstVisibleId && entry.level.ordinal <= minimumLevel.ordinal && filter.matches(entry) + } } val document = remember { ConsoleLogDocument() } @@ -169,7 +179,7 @@ private fun ConsoleLog(console: HollowIdeConsole) { id = "console-log", tags = listOf("console-log"), scrollState = logScroll, - syntaxHighlighter = highlighter, + insight = UiCodeInsight(highlighter = highlighter), modifier = Modifier.size(100.percent, 0.px).grow(1f).onScroll { event -> if (event.isCtrlDown()) { HollowIdeConsoleFontSize.zoom(event.rawScrollY) @@ -211,16 +221,17 @@ private fun ConsoleInput(console: HollowIdeConsole) { value = console.input, mode = if (multiline) UiTextFieldMode.MULTI_LINE else UiTextFieldMode.SINGLE_LINE, onChange = { console.input = it }, - syntaxHighlighter = if (kotlinMode) console.kotlin.highlighter else console.commands.highlighter, - completionContributor = if (kotlinMode) console.kotlin.completions else console.commands.contributor, - signatureHelpProvider = if (kotlinMode) console.kotlin.signatures else null, - hoverInfoProvider = if (kotlinMode) console.kotlin.hover else null, - inlayHintsProvider = if (kotlinMode) kotlinInlays else null, - inlayRevision = console.assistRevision, - completionRevision = console.assistRevision, - diagnostics = console.diagnostics(), - indentSize = if (kotlinMode) 4 else null, - autoPairs = kotlinMode, + insight = UiCodeInsight( + highlighter = if (kotlinMode) console.kotlin.highlighter else console.commands.highlighter, + completions = if (kotlinMode) console.kotlin.completions else console.commands.contributor, + signatureHelp = if (kotlinMode) console.kotlin.signatures else null, + hover = if (kotlinMode) console.kotlin.hover else null, + diagnostics = console.diagnostics(), + inlayHintsProvider = if (kotlinMode) kotlinInlays else null, + completionRevision = console.assistRevision, + inlayRevision = console.assistRevision, + ), + editing = if (kotlinMode) UiCodeEditing(multiCaret = false, syntax = KotlinCodeSyntax) else null, wrap = false, fontFamily = LogFontFamily, textShadow = null, @@ -417,3 +428,4 @@ private const val RunIcon = "hollowengine:textures/gui/icons/play.svg" private const val StopIcon = "hollowengine:textures/gui/icons/stop.svg" private const val MaximizeIcon = "hollowengine:textures/gui/icons/maximize.svg" private const val MinimizeIcon = "hollowengine:textures/gui/icons/minimize.svg" +private const val ClearIcon = "hollowengine:textures/gui/icons/actions/delete.svg" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleState.kt index 9d8200fb0..cb41f7c12 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleState.kt @@ -10,6 +10,7 @@ import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.scripting.ConsoleScripts import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeEditorSession import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic +import ru.hollowhorizon.hollowengine.common.logging.HollowLogStore import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource /** What the console's input line does with its text. */ @@ -30,6 +31,8 @@ internal class HollowIdeConsole { var minimumLevel by mutableStateOf(StandardLevel.DEBUG) var filterText by mutableStateOf("") var autoScroll by mutableStateOf(true) + var firstVisibleLogId by mutableStateOf(0L) + private set var mode by mutableStateOf(ConsoleInputMode.COMMAND) var expanded by mutableStateOf(false) @@ -88,6 +91,11 @@ internal class HollowIdeConsole { fun stopSnippets() = ConsoleScripts.stop() + /** Advances only this console's view; log consumers and the persistent log keep their entries. */ + fun clearLogView() { + firstVisibleLogId = HollowLogStore.snapshot().entries.lastOrNull()?.id?.plus(1) ?: 0L + } + /** Steps through earlier inputs of the current mode; [older] goes back in time. */ fun browseHistory(older: Boolean): Boolean { input = history.getValue(mode).step(input, older) ?: return false diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFilePreview.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFilePreview.kt index 6b268d9b9..71f977972 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFilePreview.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFilePreview.kt @@ -1,9 +1,13 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.preview import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.client.history.UndoHistory +import ru.hollowhorizon.hollowengine.client.history.UndoLabel +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeExtensionPoints import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOpenFile private const val ModeLang = "hollowengine.gui.ide.preview.mode" @@ -17,11 +21,14 @@ enum class HollowIdeViewMode(val icon: String, val tooltip: String) { } /** - * A visual view of a text file, shown beside or instead of its text the way IDEA shows Markdown. + * A visual view of a text file, shown beside or instead of its text the way IDEA shows Markdown. A file can have + * several, each with its own button in the tab bar: [icon] and [title], which may be a translation key. */ class HollowIdeFilePreview( val id: String, val defaultMode: HollowIdeViewMode = HollowIdeViewMode.SPLIT, + val icon: String = HollowIdeViewMode.PREVIEW.icon, + val title: String = HollowIdeViewMode.PREVIEW.tooltip, private val matcher: (path: String) -> Boolean, val content: @Composable (HollowIdePreviewContext) -> Unit, ) { @@ -33,49 +40,78 @@ class HollowIdeFilePreview( fun matches(path: String): Boolean = matcher(path) } -/** Previews by file path. Kept apart from file types so read-only assets get the same preview. */ +/** + * Previews by file path: the IDE's own, then those addons contribute. Kept apart from file types so read-only + * assets get the same preview. + */ class HollowIdePreviewRegistry { private val previews = mutableListOf() - @Synchronized fun register(preview: HollowIdeFilePreview) { require(previews.none { it.id == preview.id }) { "Preview '${preview.id}' is already registered" } previews += preview } - @Synchronized - fun find(path: String): HollowIdeFilePreview? = previews.firstOrNull { it.matches(path) } + fun find(path: String): List = + (previews + HollowIdeExtensionPoints.PREVIEWS.registrations.map { it.value }).filter { it.matches(path) } } -/** What one open file remembers about its preview while it stays open. */ -class HollowIdeFileView internal constructor(val preview: HollowIdeFilePreview) { - var mode by mutableStateOf(preview.defaultMode) +/** Something wrong with a file that a preview has found, and how to show it there. */ +class HollowIdePreviewProblem(val diagnostic: UiTextDiagnostic, val reveal: () -> Unit = {}) + +/** What one open file remembers about its previews while it stays open. */ +class HollowIdeFileView internal constructor(val previews: List) { + var mode by mutableStateOf(previews.first().defaultMode) + + /** The preview shown in [HollowIdeViewMode.PREVIEW] and beside the text in [HollowIdeViewMode.SPLIT]. */ + var preview by mutableStateOf(previews.first()) + + /** What the preview shown now says is wrong with the file. */ + var problems by mutableStateOf(emptyList()) + internal set /** The text's share of the width in [HollowIdeViewMode.SPLIT]. */ var split by mutableStateOf(0.5f) internal set - private var state: Any? = null + private val states = HashMap() - /** State the preview keeps across tab switches, made on first use. */ + /** State [preview] keeps across tab switches, made on first use. */ @Suppress("UNCHECKED_CAST") - fun state(create: () -> T): T = (state as? T) ?: create().also { state = it } + internal fun state(preview: HollowIdeFilePreview, create: () -> T): T = + (states[preview.id] as? T) ?: create().also { states[preview.id] = it } + + /** Lets go of what the previews keep, as the file closes. */ + internal fun close() = states.values.filterIsInstance().forEach(AutoCloseable::close) } class HollowIdePreviewContext internal constructor( val file: HollowIdeOpenFile, val view: HollowIdeFileView, - private val write: (String) -> Unit, + private val preview: HollowIdeFilePreview, + /** The history of the file's text, which the preview's edits go into. */ + val history: UndoHistory, + private val write: (next: String, label: UndoLabel?, mergeKey: String?) -> Unit, ) { val text: String get() = file.text val readOnly: Boolean get() = file.readOnly - /** Replaces the whole text; the editor diffs it into one undoable step. */ - fun edit(next: String) { - if (!readOnly && next != text) write(next) + /** + * Replaces the whole text as one undoable step. + * + * @param label what the step is called in the history window. + * @param mergeKey folds the step into the one just before with the same key, as for typing into one field. + */ + fun edit(next: String, label: UndoLabel? = null, mergeKey: String? = null) { + if (!readOnly && next != text) write(next, label, mergeKey) } - fun state(create: () -> T): T = view.state(create) + fun state(create: () -> T): T = view.state(preview, create) + + /** Tells the corner of the tab and the Problems page what is wrong with the file. */ + fun publishProblems(problems: List) { + view.problems = problems + } } /** diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFileViewCompose.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFileViewCompose.kt index 81386d917..698316061 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFileViewCompose.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/preview/HollowIdeFileViewCompose.kt @@ -47,11 +47,14 @@ private fun SplitDivider(view: HollowIdeFileView, id: String) { ) } -/** The three mode buttons at the right end of the tab bar, as in IDEA. */ +/** + * The mode buttons at the right end of the tab bar, as in IDEA: the text, the split, then a button per preview. While + * split, picking another preview puts it beside the text, and picking the one there opens it alone. + */ @Composable internal fun HollowIdeViewModeSwitch(view: HollowIdeFileView, id: String) { Row(tags = listOf("ide-view-modes"), modifier = Modifier.alignItems(vertical = UiAlign.CENTER)) { - HollowIdeViewMode.entries.forEach { mode -> + listOf(HollowIdeViewMode.TEXT, HollowIdeViewMode.SPLIT).forEach { mode -> IconButton( mode.icon, mode.tooltip.lang, @@ -60,6 +63,20 @@ internal fun HollowIdeViewModeSwitch(view: HollowIdeFileView, id: String) { tags = listOf("ide-view-mode"), ) { view.mode = mode } } + view.previews.forEach { preview -> + IconButton( + preview.icon, + preview.title.lang, + id = "$id-view-${preview.id}", + active = view.preview == preview && (view.mode == HollowIdeViewMode.PREVIEW || + view.mode == HollowIdeViewMode.SPLIT && view.previews.size > 1), + tags = listOf("ide-view-mode"), + ) { + val swap = view.mode == HollowIdeViewMode.SPLIT && view.preview != preview + view.preview = preview + if (!swap) view.mode = HollowIdeViewMode.PREVIEW + } + } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt new file mode 100644 index 000000000..a52f4206b --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatter.kt @@ -0,0 +1,20 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import kotlin.math.floor + +/** Shows a day time in ticks as the clock on the wall: tick 0 is 06:00. */ +object TimeOfDayValueFormatter : ChannelValueFormatter { + const val DAY_TICKS = 24_000L + + override fun format(value: Float): String { + val dayTicks = DAY_TICKS.toFloat() + val normalized = ((value % dayTicks) + dayTicks) % dayTicks + val minutesSinceSix = floor(normalized * MINUTES_PER_DAY / dayTicks).toInt() + val totalMinutes = (minutesSinceSix + SIX_AM_MINUTES) % MINUTES_PER_DAY + return "%02d:%02d".format(totalMinutes / MINUTES_PER_HOUR, totalMinutes % MINUTES_PER_HOUR) + } + + private const val MINUTES_PER_HOUR = 60 + private const val MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR + private const val SIX_AM_MINUTES = 6 * MINUTES_PER_HOUR +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt index a004282e9..2e56cfa04 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineApi.kt @@ -343,6 +343,8 @@ interface TrackListing { class TrackGroup(name: String) { var nameState by mutableStateOf(name) + /** Whether the scene object represented by this group is selected. */ + var isSelected by mutableStateOf(false) var isCollapsed by mutableStateOf(false) var isLocked by mutableStateOf(false) var isVisible by mutableStateOf(true) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt index 269cbc79d..984f6e480 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineController.kt @@ -856,7 +856,8 @@ class TimelineController(override val history: UndoHistory = UndoHistory()) : Un } -internal fun AnimProperty<*>.decomposeDefault(into: FloatArray) { +/** Writes the channels of the property's default value into [into]. */ +fun AnimProperty<*>.decomposeDefault(into: FloatArray) { (type as PropertyType).decompose(defaultValue, into) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt index 77610df3a..4f9d3ec4b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt @@ -21,6 +21,8 @@ object TimelineKeys { /** What each action does to the timeline; false when it had nothing to act on, so the key goes on. */ private val commands: List Boolean>> = listOf( + HollowKeys.SELECT_ALL to { it.selectAllKeys().let { true } }, + HollowKeys.SELECT_KEY_COLUMNS to { it.selectKeyColumns().let { true } }, HollowKeys.TOGGLE_CURVES to { timeline -> if (timeline.viewMode == TimelineViewMode.CURVES) timeline.viewMode = TimelineViewMode.DOPE_SHEET else timeline.enterCurveView() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt new file mode 100644 index 000000000..61b9c712f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelection.kt @@ -0,0 +1,41 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import kotlin.math.abs + +/** Keys available to selection commands, including channels inside folded groups. */ +private fun TimelineController.selectableCurves(respectFocus: Boolean = true): List = buildList { + fun visit(group: TrackGroup) { + if (!group.hasListedContent || group.isLocked || !group.isVisible) return + group.properties.filter { it.isListed && it.isVisible && !it.isLocked }.forEach { property -> + property.curves.filterTo(this) { curve -> + curve.isListed && curve.isVisible && (viewMode != TimelineViewMode.CURVES || + curve.spec.supportsCurveEditor && (!respectFocus || focusedCurves.isEmpty() || isFocused(curve))) + } + } + group.children.forEach(::visit) + } + groups.forEach(::visit) +} + +fun TimelineController.selectAllKeys() = select(selectableCurves().flatMap { it.keyframes }, additive = false) + +/** Extends a pose selection across channels; with no selected keys, uses the playhead. */ +fun TimelineController.selectKeyColumns() { + val times = selectedKeyframes.map { it.time }.ifEmpty { listOf(currentTime) }.sorted() + val keys = selectableCurves().flatMap { curve -> + curve.keyframes.filter { key -> + val index = times.binarySearch(key.time) + if (index >= 0) true else { + val insertion = -index - 1 + listOfNotNull(times.getOrNull(insertion), times.getOrNull(insertion - 1)) + .any { abs(it - key.time) <= ChannelCurve.KEY_TIME_EPSILON } + } + } + } + select(keys, additive = true) +} + +fun TimelineController.selectTrackKeys(curves: List, additive: Boolean = false) { + val available = selectableCurves(respectFocus = false).toSet() + select(curves.filter { it in available }.flatMap { it.keyframes }, additive) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt index c42dbc77f..9e076f4aa 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSnapshot.kt @@ -89,6 +89,4 @@ object TimelineEdits { val ROTATION_BASIS = label("rotation_basis") val RECORD_KEYS = label("record_keys") val CAPTURE_KEY = label("capture_key") - val MOVE_CUTSCENE = label("move_cutscene") - val REANCHOR_CUTSCENE = label("reanchor_cutscene") } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/playback/PlaybackPreviewData.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/playback/PlaybackPreviewData.kt index 5564f623d..0a420a1f9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/playback/PlaybackPreviewData.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/playback/PlaybackPreviewData.kt @@ -5,6 +5,7 @@ import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorButton import ru.hollowhorizon.hollowengine.client.ui.inspector.Section import ru.hollowhorizon.hollowengine.client.ui.widgets.ExpressionEditing +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeInsight import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextFieldMode import ru.hollowhorizon.hollowengine.common.utils.expressions.Coercions import ru.hollowhorizon.hollowengine.common.utils.expressions.Declarations @@ -78,9 +79,11 @@ internal fun PreviewDataSection(preview: PlaybackPreview) { value = preview.script, id = "playback-preview-data", mode = UiTextFieldMode.MULTI_LINE, - completionContributor = PlaybackPreviewData.editing.completions, - syntaxHighlighter = PlaybackPreviewData.editing.highlighter, - diagnostics = PlaybackPreviewData.editing.diagnostics(preview.script), + insight = UiCodeInsight( + completions = PlaybackPreviewData.editing.completions, + highlighter = PlaybackPreviewData.editing.highlighter, + diagnostics = PlaybackPreviewData.editing.diagnostics(preview.script), + ), placeholder = "d.waiting = true;", fontSize = 9f, onChange = preview::edit, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransform.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransform.kt index 195b83e53..bd4adc1d8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransform.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransform.kt @@ -122,6 +122,11 @@ internal class CurveTransform private constructor( fun click(button: Int): GizmoKeyResult = finish(confirm = button != GLFW.GLFW_MOUSE_BUTTON_RIGHT) + fun switchMode(mode: GizmoEditMode): CurveTransform { + finish(confirm = false) + return CurveTransform(controller, mode, view, pointerX, pointerY, keys, handles) + } + private fun constrain(index: Int): GizmoKeyResult { axis = if (axis == index) null else index update(pointerX, pointerY) @@ -306,6 +311,11 @@ internal class CurveTransformHost { /** A key on the timeline: the keys of the transform under way, or G, R or S starting one over the graph. */ fun key(controller: TimelineController, key: Int, modifiers: Int): Boolean { active?.let { transform -> + val mode = CurveTransform.modeFor(key, modifiers) + if (mode != null && mode != transform.mode) { + active = transform.switchMode(mode) + return true + } // Nothing else on the timeline acts while a transform holds the keys. if (transform.key(key, modifiers) .let { it == GizmoKeyResult.CONFIRMED || it == GizmoKeyResult.CANCELLED } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt index 572207c4a..cb2062221 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineChrome.kt @@ -61,11 +61,7 @@ class TimelineFeatures( val playback: Boolean = true, /** Auto-keying, for an owner that turns edits into keys while [TimelineController.isRecording] is on. */ val record: Boolean = false, -) { - companion object { - val CUTSCENE = TimelineFeatures() - } -} +) @Composable internal fun TimelineToolbar( @@ -74,7 +70,7 @@ internal fun TimelineToolbar( onSave: () -> Unit, onLoad: () -> Unit, refresh: () -> Unit, - features: TimelineFeatures = TimelineFeatures.CUTSCENE, + features: TimelineFeatures = TimelineFeatures(), ) { var menuOpen by remember { mutableStateOf(false) } var snapOpen by remember { mutableStateOf(false) } @@ -96,7 +92,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.TO_START, "timeline-start", - HollowKeys.TO_START.hint(CutsceneLang.TO_START.lang) + HollowKeys.TO_START.hint(TimelineLang.TO_START.lang) ) { controller.isPlaying = false controller.applyCurrentTime(0f) @@ -105,14 +101,14 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.PREV_KEY, "timeline-prev-key", - HollowKeys.PREVIOUS_KEY.hint(CutsceneLang.PREV_KEY.lang) + HollowKeys.PREVIOUS_KEY.hint(TimelineLang.PREV_KEY.lang) ) { if (controller.jumpToKey(-1)) refresh() } TimelineButton( if (controller.isPlaying) TimelineIcons.PAUSE else TimelineIcons.PLAY, "timeline-play", - HollowKeys.PLAY.hint(CutsceneLang.PLAY.lang), + HollowKeys.PLAY.hint(TimelineLang.PLAY.lang), active = controller.isPlaying, ) { controller.togglePlayback() @@ -121,11 +117,11 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.NEXT_KEY, "timeline-next-key", - HollowKeys.NEXT_KEY.hint(CutsceneLang.NEXT_KEY.lang) + HollowKeys.NEXT_KEY.hint(TimelineLang.NEXT_KEY.lang) ) { if (controller.jumpToKey(1)) refresh() } - TimelineButton(TimelineIcons.TO_END, "timeline-end", CutsceneLang.TO_END.lang) { + TimelineButton(TimelineIcons.TO_END, "timeline-end", TimelineLang.TO_END.lang) { controller.isPlaying = false controller.applyCurrentTime(controller.workAreaEnd) refresh() @@ -137,7 +133,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.DOPE_SHEET, "timeline-view-dope", - CutsceneLang.VIEW_DOPE_SHEET.lang, + TimelineLang.VIEW_DOPE_SHEET.lang, active = controller.viewMode == TimelineViewMode.DOPE_SHEET, ) { controller.viewMode = TimelineViewMode.DOPE_SHEET @@ -146,7 +142,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.CURVES, "timeline-view-curves", - CutsceneLang.VIEW_CURVES.lang, + TimelineLang.VIEW_CURVES.lang, active = controller.viewMode == TimelineViewMode.CURVES, ) { controller.enterCurveView() @@ -155,7 +151,7 @@ internal fun TimelineToolbar( if (controller.viewMode == TimelineViewMode.CURVES) TimelineButton( TimelineIcons.HANDLES, "timeline-curve-handles", - CutsceneLang.CURVE_HANDLES.lang, + TimelineLang.CURVE_HANDLES.lang, active = CurveHandleDisplay.all, ) { CurveHandleDisplay.all = !CurveHandleDisplay.all @@ -169,7 +165,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.RECORD, "timeline-record", - CutsceneLang.RECORD.lang, + TimelineLang.RECORD.lang, active = controller.isRecording, tags = listOf("danger"), ) { @@ -178,7 +174,7 @@ internal fun TimelineToolbar( } } if (features.capture) { - TimelineButton(TimelineIcons.CAPTURE, "timeline-capture", CutsceneLang.CAPTURE_KEYFRAME.lang) { + TimelineButton(TimelineIcons.CAPTURE, "timeline-capture", TimelineLang.CAPTURE_KEYFRAME.lang) { onCapture() refresh() } @@ -189,7 +185,7 @@ internal fun TimelineToolbar( TimelineButton( TimelineIcons.FRAME, "timeline-frame", - (if (curves) CutsceneLang.FRAME_CURVES else CutsceneLang.FRAME_TIME).lang, + (if (curves) TimelineLang.FRAME_CURVES else TimelineLang.FRAME_TIME).lang, ) { if (curves) controller.frameCurves() controller.requestFrameTime() @@ -199,18 +195,18 @@ internal fun TimelineToolbar( TimelineSnap.enabled = !TimelineSnap.enabled } SnapFpsField() - TimelineButton(TimelineIcons.ZOOM_OUT, "timeline-zoom-out", CutsceneLang.ZOOM_OUT.lang) { + TimelineButton(TimelineIcons.ZOOM_OUT, "timeline-zoom-out", TimelineLang.ZOOM_OUT.lang) { zoomAroundCenter(controller, 1f / TimelineZoomButtonFactor) refresh() } - TimelineButton(TimelineIcons.ZOOM_IN, "timeline-zoom-in", CutsceneLang.ZOOM_IN.lang) { + TimelineButton(TimelineIcons.ZOOM_IN, "timeline-zoom-in", TimelineLang.ZOOM_IN.lang) { zoomAroundCenter(controller, TimelineZoomButtonFactor) refresh() } // Everything that doesn't fit on the bar lives in an overflow menu. Box(modifier = Modifier.onPlaced { menuAnchor = it }) { - TimelineButton(TimelineIcons.MORE, "timeline-overflow", CutsceneLang.MORE.lang, active = menuOpen) { + TimelineButton(TimelineIcons.MORE, "timeline-overflow", TimelineLang.MORE.lang, active = menuOpen) { menuOpen = !menuOpen } } @@ -221,7 +217,7 @@ internal fun TimelineToolbar( onExpandedChange = { if (!it) menuOpen = false }, items = listOfNotNull( UiDropdownItem( - CutsceneLang.DELETE_SELECTED.lang, + TimelineLang.DELETE_SELECTED.lang, icon = TimelineIcons.DELETE, enabled = controller.selectedKeyframes.isNotEmpty(), shortcut = HollowKeys.DELETE.shortcut, @@ -229,7 +225,7 @@ internal fun TimelineToolbar( controller.deleteSelectedKeyframes(); refresh() }, UiDropdownItem( - CutsceneLang.SMOOTH_SELECTED.lang, + TimelineLang.SMOOTH_SELECTED.lang, icon = TimelineIcons.SMOOTH, enabled = controller.canEditSelectedCurves, shortcut = HollowKeys.SMOOTH_KEYS.shortcut, @@ -237,8 +233,8 @@ internal fun TimelineToolbar( controller.smoothSelectedKeyframes(); refresh() }, if (!features.cameraPreview) null else UiDropdownItem( - if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_PREVIEW_ON.lang - else CutsceneLang.CAMERA_PREVIEW_OFF.lang, + if (controller.isCameraPreviewEnabled) TimelineLang.CAMERA_PREVIEW_ON.lang + else TimelineLang.CAMERA_PREVIEW_OFF.lang, icon = TimelineIcons.FILM, closeOnClick = false, separatorBefore = true, @@ -247,12 +243,12 @@ internal fun TimelineToolbar( refresh() }, if (!features.storage) null else UiDropdownItem( - CutsceneLang.SAVE.lang, + TimelineLang.SAVE.lang, icon = TimelineIcons.SAVE, separatorBefore = true, ) { onSave() }, if (!features.storage) null else UiDropdownItem( - CutsceneLang.LOAD.lang, + TimelineLang.LOAD.lang, icon = TimelineIcons.LOAD, ) { onLoad() }, ), @@ -260,7 +256,7 @@ internal fun TimelineToolbar( } } -private fun snapTooltip(): String = HollowKeys.FREE_MOVE.hint(CutsceneLang.SNAP.lang) +private fun snapTooltip(): String = HollowKeys.FREE_MOVE.hint(TimelineLang.SNAP.lang) /** How many frames a second the timeline snaps to, dragged or typed in. */ @Composable @@ -272,9 +268,9 @@ private fun SnapFpsField() { min = 1.0, max = TimelineSnap.MaxFps.toDouble(), tags = listOfNotNull("timeline-snap-fps", "off".takeUnless { TimelineSnap.enabled }), - modifier = Modifier.tooltipOnHover(CutsceneLang.SNAP_FPS.lang), + modifier = Modifier.tooltipOnHover(TimelineLang.SNAP_FPS.lang), ) { TimelineSnap.fps = it.toInt() } - Text(CutsceneLang.SNAP_UNIT.lang, tags = listOf("timeline-duration")) + Text(TimelineLang.SNAP_UNIT.lang, tags = listOf("timeline-duration")) } /** Where the playhead is, as a field: typing a time jumps there. */ @@ -286,7 +282,7 @@ private fun TimeField(controller: TimelineController, refresh: () -> Unit) { fontSize = 9f, filter = UiTextInputFilter.DECIMAL, tags = listOf("timeline-time"), - modifier = Modifier.tooltipOnHover(CutsceneLang.TIME_HINT.lang), + modifier = Modifier.tooltipOnHover(TimelineLang.TIME_HINT.lang), onChange = { typed -> val time = typed.toFloatOrNull() ?: return@TextField if (abs(time - controller.currentTime) < TimeFieldEpsilon) return@TextField @@ -337,7 +333,7 @@ internal fun TimelineHeaders( modifier = Modifier.size(width.px, TimelineRulerHeight.px), ) { Text( - CutsceneLang.TRACKS.lang, + TimelineLang.TRACKS.lang, tags = listOf("timeline-corner-label"), modifier = Modifier.align(vertical = UiAlign.CENTER), ) @@ -390,7 +386,7 @@ private fun TrackListingButton(listing: TrackListing, modifier: Modifier) { TimelineButton( TimelineIcons.FILTER, "timeline-track-listing", - CutsceneLang.TRACKS_LISTING.lang, + TimelineLang.TRACKS_LISTING.lang, active = listing.onlySelected, tags = listOf("timeline-corner-button"), ) { open = !open } @@ -401,12 +397,12 @@ private fun TrackListingButton(listing: TrackListing, modifier: Modifier) { onExpandedChange = { if (!it) open = false }, items = listOf( UiDropdownItem( - CutsceneLang.TRACKS_ONLY_SELECTED.lang, + TimelineLang.TRACKS_ONLY_SELECTED.lang, checked = listing.onlySelected, mark = UiDropdownMark.CHECKBOX, ) { listing.onlySelected = !listing.onlySelected }, - UiDropdownItem(CutsceneLang.TRACKS_SHOW_ALL.lang, separatorBefore = true, onClick = listing::showAll), - UiDropdownItem(CutsceneLang.TRACKS_HIDE_UNSELECTED.lang, onClick = listing::hideUnselected), + UiDropdownItem(TimelineLang.TRACKS_SHOW_ALL.lang, separatorBefore = true, onClick = listing::showAll), + UiDropdownItem(TimelineLang.TRACKS_HIDE_UNSELECTED.lang, onClick = listing::hideUnselected), ), ) } @@ -436,10 +432,12 @@ private fun TimelineHeaderRow( val isCurveView = controller.viewMode == TimelineViewMode.CURVES val rowCurves = if (isCurveView) row.curves.filter { it.spec.supportsCurveEditor } else row.curves val isFocused = isCurveView && rowCurves.isNotEmpty() && rowCurves.all { controller.isFocused(it) } + val clicks = remember(row.id) { TimelineTrackClicks() } val tags = buildList { add("timeline-row") add(row.kind.name.lowercase()) if (isFocused) add("focused") + if (row.selected) add("scene-selected") if (row.locked) add("locked") if (!row.visible) add("muted") } @@ -453,6 +451,14 @@ private fun TimelineHeaderRow( event.consume() return@onClick } + if (!event.isLeftClick()) return@onClick + val now = event.frame?.nowMillis ?: return@onClick + if (clicks.click(now)) { + controller.selectTrackKeys(row.curves, additive = event.isAdditiveSelection()) + event.consume() + refresh() + return@onClick + } // In the graph a click picks what the graph shows; ctrl adds to it. Everywhere else // the row list is what it always was. if (isCurveView && rowCurves.isNotEmpty()) { @@ -460,7 +466,7 @@ private fun TimelineHeaderRow( } when { row.group != null -> row.group.isCollapsed = !row.group.isCollapsed - else -> if (!isCurveView) controller.clearSelection() + else -> if (!isCurveView && !event.isAdditiveSelection()) controller.clearSelection() } event.consume() refresh() @@ -507,7 +513,7 @@ private fun HeaderControls(row: TimelineRow, controller: TimelineController, ref RowToggle( "${row.id}-remove", TimelineIcons.CLOSE, - CutsceneLang.TRACK_REMOVE.lang, + TimelineLang.TRACK_REMOVE.lang, tags = listOf("hide-track") ) { if (property != null) { @@ -569,7 +575,7 @@ private fun HeaderControls(row: TimelineRow, controller: TimelineController, ref RowToggle( "${row.id}-remove", TimelineIcons.CLOSE, - CutsceneLang.TRACK_REMOVE.lang, + TimelineLang.TRACK_REMOVE.lang, tags = listOf("hide-track") ) { hide(lane) @@ -583,7 +589,7 @@ private fun HeaderControls(row: TimelineRow, controller: TimelineController, ref private fun VisibilityToggle(id: String, visible: Boolean, onClick: () -> Unit) = RowToggle( id, if (visible) TimelineIcons.EYE else TimelineIcons.EYE_OFF, - (if (visible) CutsceneLang.TRACK_HIDE else CutsceneLang.TRACK_SHOW).lang, + (if (visible) TimelineLang.TRACK_HIDE else TimelineLang.TRACK_SHOW).lang, tags = if (visible) listOf("on") else emptyList(), onClick = onClick, ) @@ -592,7 +598,7 @@ private fun VisibilityToggle(id: String, visible: Boolean, onClick: () -> Unit) private fun LockToggle(id: String, locked: Boolean, onClick: () -> Unit) = RowToggle( id, if (locked) TimelineIcons.LOCK else TimelineIcons.LOCK_OPEN, - (if (locked) CutsceneLang.TRACK_UNLOCK else CutsceneLang.TRACK_LOCK).lang, + (if (locked) TimelineLang.TRACK_UNLOCK else TimelineLang.TRACK_LOCK).lang, active = locked, onClick = onClick, ) @@ -636,8 +642,8 @@ private fun DisclosureArrow(id: String, expanded: Boolean, onClick: () -> Unit) private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: () -> Unit): List = buildList { val keys = row.curves.flatMap { it.keyframes } - add(UiDropdownItem(CutsceneLang.TRACK_SELECT_KEYS.lang, enabled = keys.isNotEmpty() && !row.locked) { - controller.select(keys, additive = false) + add(UiDropdownItem(TimelineLang.TRACK_SELECT_KEYS.lang, enabled = keys.isNotEmpty() && !row.locked) { + controller.selectTrackKeys(row.curves) refresh() }) @@ -646,7 +652,7 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: if (property != null && type != null) { add( UiDropdownItem( - CutsceneLang.ROTATION_MODE.lang, + TimelineLang.ROTATION_MODE.lang, separatorBefore = true, children = RotationMode.entries.map { mode -> UiDropdownItem(rotationModeLabel(mode), checked = type.mode == mode) { @@ -661,12 +667,12 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: val hideGroup = controller.onHideGroup val group = row.group if (property != null && controller.onHideProperty != null) { - add(UiDropdownItem(CutsceneLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { + add(UiDropdownItem(TimelineLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { controller.unlist(property, row.curve.takeIf { row.kind == TimelineRowKind.CHANNEL }) refresh() }) } else if (group != null && hideGroup != null) { - add(UiDropdownItem(CutsceneLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { + add(UiDropdownItem(TimelineLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { hideGroup(group) refresh() }) @@ -674,7 +680,8 @@ private fun trackMenu(controller: TimelineController, row: TimelineRow, refresh: } @Composable -internal fun ToolbarButton(label: String, id: String, primary: Boolean = false, onClick: () -> Unit) = Button( +/** A text button in the timeline's look; [primary] marks the one a dialog confirms with. */ +fun ToolbarButton(label: String, id: String, primary: Boolean = false, onClick: () -> Unit) = Button( label, id = id, kind = if (primary) UiButtonKind.PRIMARY else UiButtonKind.NORMAL, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt index 72101a83a..06068b8f2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineFields.kt @@ -10,8 +10,8 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint import ru.hollowhorizon.hollowengine.client.utils.lang internal fun rotationModeLabel(mode: RotationMode): String = when (mode) { - RotationMode.EULER -> CutsceneLang.ROTATION_EULER.lang - RotationMode.QUATERNION -> CutsceneLang.ROTATION_QUATERNION.lang + RotationMode.EULER -> TimelineLang.ROTATION_EULER.lang + RotationMode.QUATERNION -> TimelineLang.ROTATION_QUATERNION.lang } /** The shape of one easing preset, drawn so a curve can be picked by eye rather than by name. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt index aebc6730e..acc6ed53c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLanes.kt @@ -422,7 +422,7 @@ internal fun TimelineContextMenu( else -> buildList { val curve = state.curve if (curve != null && !state.locked) { - add(UiDropdownItem(CutsceneLang.ADD_KEYFRAME.lang, icon = PulseIcon) { + add(UiDropdownItem(TimelineLang.ADD_KEYFRAME.lang, icon = PulseIcon) { controller.edit(TimelineEdits.ADD_KEY) { controller.setKey(curve, state.time, curve.valueAt(state.time, 0f)) } @@ -431,28 +431,28 @@ internal fun TimelineContextMenu( } add( UiDropdownItem( - CutsceneLang.CURVE_PRESET.lang, + TimelineLang.CURVE_PRESET.lang, icon = PresetIcon, enabled = hasCurveSelection, closeOnClick = false, ) { showPresets = true }) - add(UiDropdownItem(CutsceneLang.SMOOTH_SELECTED.lang, icon = GraphIcon, enabled = hasCurveSelection) { + add(UiDropdownItem(TimelineLang.SMOOTH_SELECTED.lang, icon = GraphIcon, enabled = hasCurveSelection) { controller.smoothSelectedKeyframes() refresh() }) - add(UiDropdownItem(CutsceneLang.COPY_SELECTED.lang, icon = CopyIcon, enabled = hasSelection) { + add(UiDropdownItem(TimelineLang.COPY_SELECTED.lang, icon = CopyIcon, enabled = hasSelection) { controller.copySelectedKeyframes() refresh() }) - add(UiDropdownItem(CutsceneLang.CUT_SELECTED.lang, icon = CutIcon, enabled = hasSelection) { + add(UiDropdownItem(TimelineLang.CUT_SELECTED.lang, icon = CutIcon, enabled = hasSelection) { controller.cutSelectedKeyframes() refresh() }) - add(UiDropdownItem(CutsceneLang.PASTE.lang, icon = PasteIcon, enabled = controller.canPaste) { + add(UiDropdownItem(TimelineLang.PASTE.lang, icon = PasteIcon, enabled = controller.canPaste) { controller.pasteKeyframes(state.time) refresh() }) - add(UiDropdownItem(CutsceneLang.DELETE_SELECTED.lang, icon = RemoveIcon, enabled = hasSelection) { + add(UiDropdownItem(TimelineLang.DELETE_SELECTED.lang, icon = RemoveIcon, enabled = hasSelection) { controller.deleteSelectedKeyframes() refresh() }) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt index c3e697c16..10be3ab6b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelinePanel.kt @@ -5,19 +5,13 @@ import kotlinx.coroutines.isActive import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.keymap.HollowKeys import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.ide.CutsceneIcon import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelinePlayheadHold import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineViewMode -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSession -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneStorage -import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget -import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint -import ru.hollowhorizon.hollowengine.client.utils.lang import kotlin.math.ceil import kotlin.math.floor import kotlin.math.round @@ -28,69 +22,8 @@ private const val TimelineValuePanStep = 0.08f internal const val TimelineMinValueSpan = 0.001f internal const val TimelineMaxValueSpan = 100_000f -internal val LocalTimelineRevision = staticCompositionLocalOf { 0 } - -@Composable -fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean = true) { - session.uiRevision.value - var dialog by remember { mutableStateOf(CutsceneDialog.NONE) } - - LaunchedEffect(session) { - var lastNanos = -1L - while (isActive) { - withFrameNanos { now -> - val delta = - if (lastNanos < 0L) 0f else ((now - lastNanos) / 1_000_000_000.0).toFloat().coerceIn(0f, 0.1f) - lastNanos = now - if (session.timeline.isPlaying) session.update(delta) - if (session.timeline.curveAxis.advance(now)) session.invalidateUi() - } - } - } - - PublishInspector(source = "cutscene", key = cutsceneInspectorKey(session)) { - cutsceneInspectorTarget(session) - } - - CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) { - Box(id = "cutscene-timeline-dock", mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { - HollowTimelineEditor( - controller = session.timeline, - refresh = session::invalidateUi, - onKeyInput = session::onHollowUiKey, - keyboardActive = keyboardActive, - onCapture = { session.captureFrame(session.timeline.currentTime) }, - onSave = { dialog = CutsceneDialog.SAVE }, - onLoad = { dialog = CutsceneDialog.LOAD }, - ) - - when (dialog) { - CutsceneDialog.SAVE -> SaveCutsceneDialog(session) { dialog = CutsceneDialog.NONE } - CutsceneDialog.LOAD -> LoadCutsceneDialog(session) { dialog = CutsceneDialog.NONE } - CutsceneDialog.NONE -> {} - } - } - } -} - -private fun cutsceneInspectorTarget(session: CutsceneEditorSession) = InspectorTarget( - id = "cutscene-${cutsceneInspectorKey(session)}", - title = CutsceneLang.PROPERTIES.lang, - icon = CutsceneIcon, -) { - CompositionLocalProvider(LocalTimelineRevision provides session.uiRevision.value) { - HollowTimelineProperties(session, refresh = session::invalidateUi) - } -} - -private fun cutsceneInspectorKey(session: CutsceneEditorSession): String { - val controller = session.timeline - val keyframe = controller.selectedKeyframes.firstOrNull() - ?: return if (controller.isWorkAreaSelected) "work-area" else "none" - return "key-${controller.curveOf(keyframe)?.spec?.name}-${keyframe.time}" -} - -private enum class CutsceneDialog { NONE, SAVE, LOAD } +/** Changes whenever the edited timeline does; content drawn next to the editor reads it to stay in step. */ +val LocalTimelineRevision = staticCompositionLocalOf { 0 } @Composable fun HollowTimelineEditor( @@ -101,7 +34,7 @@ fun HollowTimelineEditor( onCapture: () -> Unit = {}, onSave: () -> Unit = {}, onLoad: () -> Unit = {}, - features: TimelineFeatures = TimelineFeatures.CUTSCENE, + features: TimelineFeatures = TimelineFeatures(), ) { val bump = refresh val scroll = rememberScrollState() @@ -485,109 +418,3 @@ internal fun Playhead(controller: TimelineController, pxPerSec: Float, rowsHeigh .inputTransparent(), ) } - -// --------------------------------------------------------------------------- -// Save / Load dialogs (lightweight overlays over the timeline dock) -// --------------------------------------------------------------------------- - -@Composable -private fun SaveCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) { - var name by remember { mutableStateOf(session.playback.toData().name) } - DialogFrame(CutsceneLang.SAVE_TITLE.lang, onClose) { - Text(CutsceneLang.NAME.lang, modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted)) - TextField( - value = name, - onChange = { name = it }, - modifier = Modifier.size(100.percent, 24.px) - .background(TimelineColors.Background) - .border(1.px, TimelineColors.Border, 3f) - .padding(6.px, 2.px) - .foreground(TimelineColors.Text) - .fontSize(11f), - ) - Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(8.px).align(horizontal = UiAlign.END)) { - ToolbarButton(CutsceneLang.CANCEL.lang, "cutscene-save-cancel") { onClose() } - ToolbarButton(CutsceneLang.SAVE.lang, "cutscene-save-confirm", primary = true) { - if (name.isNotBlank()) { - session.exportCutscene("", name.trim()) - onClose() - } - } - } - } -} - -@Composable -private fun LoadCutsceneDialog(session: CutsceneEditorSession, onClose: () -> Unit) { - val files = remember { CutsceneStorage.listFiles() } - DialogFrame(CutsceneLang.LOAD_TITLE.lang, onClose) { - if (files.isEmpty()) { - Text(CutsceneLang.NO_CUTSCENES.lang, modifier = Modifier.fontSize(11f).foreground(TimelineColors.Muted)) - } else { - Column( - modifier = Modifier.size(100.percent, UiLength.Auto) - .gap(2.px) - .scrollable(horizontal = false), - ) { - files.forEach { file -> - Box( - modifier = Modifier.size(100.percent, 24.px) - .background(TimelineColors.Background) - .border(1.px, TimelineColors.Border, 3f) - .cursor(UiCursorShape.HAND) - .onClick { - session.importCutscene(file) - onClose() - it.consume() - }, - ) { - Text( - file.removeSuffix(".${CutsceneStorage.EXTENSION}"), - modifier = Modifier.align(vertical = UiAlign.CENTER) - .margin(8.px, 0.px, 0.px, 0.px) - .fontSize(11f) - .foreground(TimelineColors.Text), - ) - } - } - } - } - Row(modifier = Modifier.size(100.percent, UiLength.Auto).align(horizontal = UiAlign.END)) { - ToolbarButton(CutsceneLang.CLOSE.lang, "cutscene-load-close") { onClose() } - } - } -} - -@Composable -internal fun DialogFrame(title: String, onClose: () -> Unit, content: HollowUiContent) { - val viewport = LocalUiViewport.current - Popup( - anchorBounds = viewport, - alignment = CenteredOnAnchor, - id = "cutscene-dialog-popup", - layer = 100, - modal = true, - onDismiss = onClose, - ) { - Column( - id = "cutscene-dialog", - modifier = Modifier.size(340.px, UiLength.Auto) - .maxSize(height = (viewport.height - 80f).coerceAtLeast(120f).px) - .background(TimelineColors.Panel) - .border(1.px, TimelineColors.Border, 6f) - .padding(14.px) - .gap(8.px) - .scrollable(horizontal = false), - ) { - Text(title, modifier = Modifier.fontSize(13f).foreground(TimelineColors.Text)) - content() - } - } -} - -private val CenteredOnAnchor = UiPopupAlignment( - anchorHorizontal = UiAlign.CENTER, - anchorVertical = UiAlign.CENTER, - popupHorizontal = UiAlign.CENTER, - popupVertical = UiAlign.CENTER, -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt index a76d7f047..d0ebeb07e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineProperties.kt @@ -3,18 +3,8 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui import androidx.compose.runtime.* import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSession import ru.hollowhorizon.hollowengine.client.ui.inspector.* import ru.hollowhorizon.hollowengine.client.utils.lang -import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f - -/** The cutscene's own fields, composed inside whichever panel the shared inspector is drawn in. */ -@Composable -internal fun HollowTimelineProperties(session: CutsceneEditorSession, refresh: () -> Unit) { - PreviewSection(session.timeline, refresh) - OriginSection(session, refresh) - TimelineSelectionFields(session.timeline, refresh) { WorldReadout(session) } -} /** * What is selected on a timeline: the keys and their curve, or the work area. @@ -35,8 +25,8 @@ fun TimelineSelectionFields( CurveSection(controller, selectedKey, selectedCurve, refresh) } InspectorButton( - if (controller.selectedKeyframes.size == 1) CutsceneLang.DELETE_KEY.lang - else CutsceneLang.DELETE_KEYS.lang, + if (controller.selectedKeyframes.size == 1) TimelineLang.DELETE_KEY.lang + else TimelineLang.DELETE_KEYS.lang, tags = listOf("danger"), ) { controller.deleteSelectedKeyframes() @@ -59,65 +49,6 @@ fun TimelineController.selectionKey(): String { return "key-${System.identityHashCode(curveOf(keyframe))}-${System.identityHashCode(keyframe)}" } -@Composable -private fun PreviewSection(controller: TimelineController, refresh: () -> Unit) { - Section(CutsceneLang.PREVIEW.lang) { - Row( - modifier = Modifier.size(100.percent, 24.px) - .alignItems(vertical = UiAlign.CENTER) - .gap(8.px) - ) { - Pill( - if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_ON.lang - else CutsceneLang.CAMERA_OFF.lang, - controller.isCameraPreviewEnabled - ) { - controller.applyCameraPreviewEnabled(!controller.isCameraPreviewEnabled) - refresh() - } - Text( - if (controller.isPlaying) CutsceneLang.PLAYING.lang else CutsceneLang.PAUSED.lang, - modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted), - ) - } - Readonly(CutsceneLang.CURRENT_TIME.lang, "%.3f s".format(controller.currentTime).replace(',', '.')) - Readonly(CutsceneLang.DURATION.lang, "%.3f s".format(controller.workAreaEnd).replace(',', '.')) - } -} - -@Composable -private fun OriginSection(session: CutsceneEditorSession, refresh: () -> Unit) { - val origin = session.playback.origin - Section(CutsceneLang.ORIGIN.lang, id = "timeline-origin-section") { - NumberRow(CutsceneLang.ORIGIN_X.lang, origin.x) { next -> - session.moveOrigin(Vec3f(next, origin.y, origin.z), origin.yaw, keepWorld = false) - refresh() - } - NumberRow(CutsceneLang.ORIGIN_Y.lang, origin.y) { next -> - session.moveOrigin(Vec3f(origin.x, next, origin.z), origin.yaw, keepWorld = false) - refresh() - } - NumberRow(CutsceneLang.ORIGIN_Z.lang, origin.z) { next -> - session.moveOrigin(Vec3f(origin.x, origin.y, next), origin.yaw, keepWorld = false) - refresh() - } - NumberRow(CutsceneLang.ORIGIN_YAW.lang, origin.yaw, min = -360f, max = 360f, step = 1f) { next -> - session.moveOrigin(origin.position, next, keepWorld = false) - refresh() - } - Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(6.px)) { - ToolbarButton(CutsceneLang.ORIGIN_MOVE_HERE.lang, "timeline-origin-move") { - session.originToPlayer(keepWorld = false) - refresh() - } - ToolbarButton(CutsceneLang.ORIGIN_REBASE.lang, "timeline-origin-rebase") { - session.originToPlayer(keepWorld = true) - refresh() - } - } - } -} - @Composable private fun KeyframeSection( controller: TimelineController, @@ -127,10 +58,10 @@ private fun KeyframeSection( extras: @Composable () -> Unit, ) { val count = controller.selectedKeyframes.size - val title = if (count == 1) CutsceneLang.KEYFRAME.lang else CutsceneLang.KEYFRAMES.lang(count) + val title = if (count == 1) TimelineLang.KEYFRAME.lang else TimelineLang.KEYFRAMES.lang(count) Section(title) { - Readonly(CutsceneLang.CHANNEL.lang, channelPath(controller, curve)) - NumberRow(CutsceneLang.TIME.lang, keyframe.time) { time -> + Readonly(TimelineLang.CHANNEL.lang, channelPath(controller, curve)) + NumberRow(TimelineLang.TIME.lang, keyframe.time) { time -> controller.nudgeSelectedKeyframes(snapTimelineTime(time) - keyframe.time) refresh() } @@ -139,7 +70,7 @@ private fun KeyframeSection( val channel = owner?.curves?.indexOfFirst { it === curve } ?: -1 val bounds = (if (channel >= 0) owner?.bounds(channel) else null) ?: ChannelBounds.Unbounded NumberRow( - CutsceneLang.VALUE.lang, + TimelineLang.VALUE.lang, keyframe.value, min = bounds.minimum ?: -Float.MAX_VALUE, max = bounds.maximum ?: Float.MAX_VALUE, @@ -159,7 +90,7 @@ private fun KeyframeSection( @Composable private fun DiscreteValueField(options: List, value: Float, onChange: (Float) -> Unit) { - Text(CutsceneLang.VALUE.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) + Text(TimelineLang.VALUE.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) PillFlow(id = "timeline-discrete-values") { options.forEachIndexed { index, option -> Pill(option.labelKey.lang, value == option.value, id = "timeline-discrete-value-$index") { @@ -174,16 +105,6 @@ private fun channelPath(controller: TimelineController, curve: ChannelCurve): St return listOfNotNull(property?.nameState, curve.name).joinToString(" / ") } -@Composable -private fun WorldReadout(session: CutsceneEditorSession) { - val pose = session.playback.currentPose - Readonly(CutsceneLang.WORLD.lang, formatVec3(pose.position)) - Readonly(CutsceneLang.WORLD_ROTATION.lang, formatVec3(pose.rotation)) -} - -private fun formatVec3(vector: Vec3f): String = - "%.2f %.2f %.2f".format(vector.x, vector.y, vector.z).replace(',', '.') - /** * Interpolation is a curve preset: picking one writes the handles that shape the segment, and the * handles can then be dragged from here or in the graph editor. @@ -200,7 +121,7 @@ private fun CurveSection( val active = CurvePresets.match(keyframe, next) var category by remember { mutableStateOf(active?.category ?: CurvePresets.categories.first()) } - Section(CutsceneLang.INTERPOLATION.lang, id = "timeline-interpolation-section") { + Section(TimelineLang.INTERPOLATION.lang, id = "timeline-interpolation-section") { active?.let { CurvePreview(it) } PillFlow(id = "timeline-preset-categories") { CurvePresets.categories.forEach { name -> @@ -217,7 +138,7 @@ private fun CurveSection( } if (keyframe.interpolation != KeyInterpolation.BEZIER) return@Section - Text(CutsceneLang.HANDLES.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) + Text(TimelineLang.HANDLES.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) PillFlow(id = "timeline-handle-modes") { HandleMode.entries.forEach { mode -> Pill(handleLabel(mode), keyframe.handleMode == mode, id = "handle-mode-${mode.name}") { @@ -228,23 +149,23 @@ private fun CurveSection( } if (keyframe.handleMode == HandleMode.AUTO) { Text( - CutsceneLang.HANDLES_AUTO_HINT.lang, + TimelineLang.HANDLES_AUTO_HINT.lang, modifier = Modifier.size(100.percent, UiLength.Fit) .fontSize(9f) .foreground(TimelineColors.Muted), ) return@Section } - TangentRow(controller, keyframe, TangentSide.INCOMING, CutsceneLang.HANDLE_IN.lang, refresh) - TangentRow(controller, keyframe, TangentSide.OUTGOING, CutsceneLang.HANDLE_OUT.lang, refresh) + TangentRow(controller, keyframe, TangentSide.INCOMING, TimelineLang.HANDLE_IN.lang, refresh) + TangentRow(controller, keyframe, TangentSide.OUTGOING, TimelineLang.HANDLE_OUT.lang, refresh) } } private fun handleLabel(mode: HandleMode): String = when (mode) { - HandleMode.AUTO -> CutsceneLang.HANDLES_AUTO.lang - HandleMode.MIRRORED -> CutsceneLang.HANDLES_MIRRORED.lang - HandleMode.ALIGNED -> CutsceneLang.HANDLES_ALIGNED.lang - HandleMode.FREE -> CutsceneLang.HANDLES_FREE.lang + HandleMode.AUTO -> TimelineLang.HANDLES_AUTO.lang + HandleMode.MIRRORED -> TimelineLang.HANDLES_MIRRORED.lang + HandleMode.ALIGNED -> TimelineLang.HANDLES_ALIGNED.lang + HandleMode.FREE -> TimelineLang.HANDLES_FREE.lang } @Composable @@ -281,8 +202,8 @@ private fun applyTangent( @Composable private fun WorkAreaSection(controller: TimelineController, refresh: () -> Unit) { - Section(CutsceneLang.WORK_AREA.lang) { - NumberRow(CutsceneLang.END.lang, controller.workAreaEnd, min = 0.1f, max = Float.POSITIVE_INFINITY) { time -> + Section(TimelineLang.WORK_AREA.lang) { + NumberRow(TimelineLang.END.lang, controller.workAreaEnd, min = 0.1f, max = Float.POSITIVE_INFINITY) { time -> controller.workAreaEnd = snapTimelineTime(time).coerceAtLeast(0.1f) if (controller.currentTime > controller.workAreaEnd) controller.applyCurrentTime(0f) refresh() @@ -292,9 +213,9 @@ private fun WorkAreaSection(controller: TimelineController, refresh: () -> Unit) @Composable private fun EmptySection() { - Section(CutsceneLang.SELECTION.lang) { + Section(TimelineLang.SELECTION.lang) { Text( - CutsceneLang.NO_SELECTION.lang, + TimelineLang.NO_SELECTION.lang, modifier = Modifier.size(100.percent, 22.px) .fontSize(11f) .foreground(TimelineColors.Muted) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt index db685b506..e0a5eb614 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineSupport.kt @@ -66,6 +66,7 @@ internal data class TimelineRow( val locked: Boolean = false, val visible: Boolean = true, val color: Color? = null, + val selected: Boolean = false, ) { val curves: List get() = when { @@ -115,6 +116,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole height = TimelineGroupRowHeight, kind = TimelineRowKind.GROUP, group = group, + selected = group.isSelected, locked = locked, visible = visible, ) @@ -143,6 +145,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole locked = propertyLocked, visible = propertyVisible && singleCurve.isVisible, color = singleCurve.color, + selected = group.isSelected, ) y += TimelinePropertyRowHeight return@forEach @@ -162,6 +165,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole locked = propertyLocked, visible = propertyVisible && curve.isVisible, color = curve.color, + selected = group.isSelected, ) y += TimelineChannelRowHeight } @@ -278,7 +282,7 @@ internal fun UiColor.withAlpha(multiplier: Float) = * What the lanes, keys and curves are drawn with: the IDE palette of docking.hss, so the timeline * reads as part of the IDE. The chrome around them takes the same colors from timeline.hss. */ -internal object TimelineColors { +object TimelineColors { val Background = UiColor(0.110f, 0.118f, 0.133f, 1f) val Panel = UiColor(0.110f, 0.118f, 0.133f, 1f) val Row = UiColor(0.118f, 0.125f, 0.141f, 1f) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt index bcd421812..2bec33617 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt @@ -2,15 +2,16 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui import androidx.compose.runtime.* import kotlinx.coroutines.isActive +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.CutsceneIcon import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineKeys -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSessions import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorStylesheet import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.utils.rl /** * What the timeline window is showing. @@ -33,6 +34,18 @@ class TimelineTarget( val overlay: HollowUiContent? = null, ) +/** + * What the timeline window shows while no editor has published a [TimelineTarget], such as the + * cutscene being authored. It draws the whole window itself. + */ +interface DefaultTimeline { + /** The timeline drawn, whose history the window's undo steps through. */ + val controller: TimelineController + + @Composable + fun Content(keyboardActive: Boolean) +} + /** * Whose timeline the shared window shows, wherever it is drawn. * @@ -42,6 +55,11 @@ object IdeTimelines { var current: TimelineTarget? by mutableStateOf(null) private set + /** Contributed by addons; the first one is shown while nothing is published. */ + val defaults = ExtensionPoints.create("hollowengine:ide/default-timelines".rl, DefaultTimeline::class) + + val default: DefaultTimeline? get() = defaults.extensions.firstOrNull() + private var owner: String? = null fun publish(source: String, target: TimelineTarget?) { @@ -74,7 +92,8 @@ fun PublishTimeline(source: String, key: Any?, target: () -> TimelineTarget?) { fun TimelineDock(keyboardActive: Boolean = true) { val target = IdeTimelines.current if (target == null) { - CutsceneTimelineDock(CutsceneEditorSessions.default, keyboardActive) + val fallback = IdeTimelines.default + if (fallback != null) fallback.Content(keyboardActive) else EmptyTimeline() return } @@ -107,7 +126,7 @@ fun TimelineDock(keyboardActive: Boolean = true) { if (!controller.hasInspectableSelection()) return@PublishInspector null InspectorTarget( id = "timeline-${target.id}-${controller.selectionKey()}", - title = CutsceneLang.PROPERTIES.lang, + title = TimelineLang.PROPERTIES.lang, icon = CutsceneIcon, ) { revision.value @@ -195,3 +214,19 @@ fun EmbeddedTimelineEditor( } private const val EmbeddedSelectionWidth = 220f + +@Composable +private fun EmptyTimeline() { + Box( + id = "ide-timeline-empty", + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).background(TimelineColors.Background), + ) { + Text( + TimelineLang.EMPTY.lang, + modifier = Modifier.align(horizontal = UiAlign.CENTER, vertical = UiAlign.CENTER) + .fontSize(11f) + .foreground(TimelineColors.Muted), + ) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CutsceneLang.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineLang.kt similarity index 76% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CutsceneLang.kt rename to runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineLang.kt index 59934e573..fd7d67315 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CutsceneLang.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineLang.kt @@ -1,7 +1,8 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui -internal object CutsceneLang { - private const val ROOT = "hollowengine.gui.ide.cutscene." +/** Translation keys of the timeline editor. */ +object TimelineLang { + private const val ROOT = "hollowengine.gui.ide.timeline." const val PROPERTIES = ROOT + "properties" const val TRACKS = ROOT + "tracks" @@ -44,39 +45,20 @@ internal object CutsceneLang { const val ZOOM_OUT = ROOT + "action.zoom_out" const val MORE = ROOT + "action.more" - const val SAVE_TITLE = ROOT + "dialog.save_title" - const val LOAD_TITLE = ROOT + "dialog.load_title" - const val NAME = ROOT + "dialog.name" - const val NO_CUTSCENES = ROOT + "dialog.no_cutscenes" - - const val PREVIEW = ROOT + "section.preview" - const val ORIGIN = ROOT + "section.origin" const val KEYFRAME = ROOT + "section.keyframe" const val KEYFRAMES = ROOT + "section.keyframes" const val INTERPOLATION = ROOT + "section.interpolation" const val WORK_AREA = ROOT + "section.work_area" const val SELECTION = ROOT + "section.selection" - const val CAMERA_ON = ROOT + "preview.camera_on" - const val CAMERA_OFF = ROOT + "preview.camera_off" - const val PLAYING = ROOT + "preview.playing" - const val PAUSED = ROOT + "preview.paused" - const val CURRENT_TIME = ROOT + "preview.current_time" - const val DURATION = ROOT + "preview.duration" - const val TIME_HINT = ROOT + "preview.time_hint" + /** Shown when nothing with a timeline is open. */ + const val EMPTY = ROOT + "empty" - const val ORIGIN_X = ROOT + "origin.x" - const val ORIGIN_Y = ROOT + "origin.y" - const val ORIGIN_Z = ROOT + "origin.z" - const val ORIGIN_YAW = ROOT + "origin.yaw" - const val ORIGIN_MOVE_HERE = ROOT + "origin.move_here" - const val ORIGIN_REBASE = ROOT + "origin.rebase" + const val TIME_HINT = ROOT + "preview.time_hint" const val TIME = ROOT + "keyframe.time" const val VALUE = ROOT + "keyframe.value" const val CHANNEL = ROOT + "keyframe.channel" - const val WORLD = ROOT + "keyframe.world" - const val WORLD_ROTATION = ROOT + "keyframe.world_rotation" const val DELETE_KEY = ROOT + "keyframe.delete_key" const val DELETE_KEYS = ROOT + "keyframe.delete_keys" const val NO_SELECTION = ROOT + "keyframe.no_selection" @@ -101,4 +83,4 @@ internal object CutsceneLang { const val ROTATION_MODE = ROOT + "rotation.mode" const val ROTATION_EULER = ROOT + "rotation.euler" const val ROTATION_QUATERNION = ROOT + "rotation.quaternion" - } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt new file mode 100644 index 000000000..bc1c92fce --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineTrackClicks.kt @@ -0,0 +1,15 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui + +internal class TimelineTrackClicks { + private var previous: Long? = null + + fun click(now: Long): Boolean { + val double = previous?.let { now - it in 0..DoubleClickMillis } == true + previous = if (double) null else now + return double + } + + private companion object { + const val DoubleClickMillis = 300L + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/Inspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/Inspector.kt index 465d7ef44..c9ab61355 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/Inspector.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/Inspector.kt @@ -1,6 +1,7 @@ package ru.hollowhorizon.hollowengine.client.ui.inspector import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.client.history.UndoHistory import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState @@ -21,6 +22,7 @@ class InspectorTarget( val icon: String? = null, val subtitle: String? = null, val styles: List = emptyList(), + val history: UndoHistory? = null, val content: HollowUiContent, ) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssets.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssets.kt index 523d41037..2958d4848 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssets.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssets.kt @@ -114,8 +114,10 @@ fun AssetPathField( id = id, fontSize = 9f, placeholder = placeholder, - completionContributor = if (listed.isEmpty()) null else assetCompletions(listed), - diagnostics = diagnostics, + insight = UiCodeInsight( + completions = if (listed.isEmpty()) null else assetCompletions(listed), + diagnostics = diagnostics, + ), onChange = onChange, tags = listOf("insp-input", "insp-inline-input"), modifier = Modifier.size(0.px, UiLength.Fit).grow(1f), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAutoFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAutoFields.kt index 2da496ece..11adc9b9a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAutoFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAutoFields.kt @@ -123,7 +123,7 @@ fun KeyedField(id: String, animated: Boolean, onToggle: () -> Unit, editor: @Com private const val KeyIcon = "hollowengine:textures/gui/icons/timeline/key_add.svg" @Composable -internal fun FieldLabel(label: String?, description: String?) { +fun FieldLabel(label: String?, description: String?) { if (label == null) return FieldHelp(description) Row(tags = listOf("insp-field-head")) { @@ -133,7 +133,7 @@ internal fun FieldLabel(label: String?, description: String?) { } @Composable -internal fun FieldHelp(description: String?) { +fun FieldHelp(description: String?) { val text = description ?: return Image( @@ -318,7 +318,9 @@ private fun StringField( id = "insp-input$path", mode = if (hints.multiline) UiTextFieldMode.MULTI_LINE else UiTextFieldMode.SINGLE_LINE, fontSize = 9f, - completionContributor = if (candidates.isEmpty()) null else assetCompletions(candidates), + insight = UiCodeInsight( + completions = if (candidates.isEmpty()) null else assetCompletions(candidates), + ), onChange = { typed -> onChange(JsonPrimitive(if (hints.asset.isEmpty()) typed else sanitizeAssetPath(typed))) }, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorFields.kt index 60976e3fc..714b1afc8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorFields.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorFields.kt @@ -79,6 +79,7 @@ fun TextRow( completions: UiCompletionContributor? = null, highlighter: UiSyntaxHighlighter? = null, diagnostics: List = emptyList(), + highlightRevision: Long = 0L, trailing: HollowUiContent? = null, help: String? = null, onChange: (String) -> Unit, @@ -90,9 +91,12 @@ fun TextRow( value = value, id = id ?: "insp-input-$label", filter = filter, - completionContributor = completions, - syntaxHighlighter = highlighter, - diagnostics = diagnostics, + insight = UiCodeInsight( + completions = completions, + highlighter = highlighter, + diagnostics = diagnostics, + highlightRevision = highlightRevision, + ), fontSize = 9f, onChange = onChange, tags = listOf("insp-input"), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/SoundSourceField.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/SoundSourceField.kt index 6d27f7c76..252ec6b3d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/SoundSourceField.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/SoundSourceField.kt @@ -68,7 +68,7 @@ fun SoundAuditionButton(value: String, playback: SoundPlayback = SoundPlayback() sound = null playing = true Minecraft.getInstance().execute { - sound = ClientSounds.play(source, playback.copy(mode = SoundMode.GLOBAL), id = AUDITION) + sound = ClientSounds.play(source, playback.copy(mode = SoundMode.GLOBAL), id = AUDITION, pauseWithGame = false) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutHelpers.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutHelpers.kt index 19b1ab39c..12d3cda41 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutHelpers.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutHelpers.kt @@ -144,10 +144,13 @@ val PopupOverlayMeasurePolicy = UiMeasurePolicy { measurables, constraints -> layout(constraints.maxWidth, constraints.maxHeight) { for ((measurable, placeable) in placed) { val popup = measurable.node as? PopupNode ?: continue - val rect = popup.alignment.popupRect( - popup.anchorBounds, - LayoutSize(placeable.width, placeable.height), - ) + val size = LayoutSize(placeable.width, placeable.height) + val placed = popup.alignment.popupRect(popup.anchorBounds, size) + val outside = placed.x < 0f || placed.x + placed.width > constraints.maxWidth + val rect = if (popup.alignment.flipsHorizontally && outside) { + popup.alignment.mirrored().popupRect(popup.anchorBounds, size) + .takeIf { it.x >= 0f && it.x + it.width <= constraints.maxWidth } ?: placed + } else placed val maxX = (constraints.maxWidth - placeable.width).coerceAtLeast(0f) val maxY = (constraints.maxHeight - placeable.height).coerceAtLeast(0f) placeable.place(rect.x.coerceIn(0f, maxX), rect.y.coerceIn(0f, maxY)) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt index c5fa3858e..c9ea5d3bc 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutMeasurement.kt @@ -95,17 +95,12 @@ private inline fun UiLayoutPipeline.measureNodeCached( allowHeightOverflow: Boolean, onCacheHit: () -> Unit = {}, ): LayoutSize { - val cache = measureCacheFor(node) + val cache = node.layoutState.measureCache ?: NodeMeasureCache().also { node.layoutState.measureCache = it } val revision = node.layoutState.subtreeLayoutRevision - val key = MeasureCacheKey( - availableWidth = availableWidth, - availableHeight = availableHeight, - widthOverride = widthOverride, - heightOverride = heightOverride, - flags = measureFlags(deferFlexibleWidth, deferFlexibleHeight, allowWidthOverflow, allowHeightOverflow), - scrollbarReserves = scrollbarReserves, - ) - cache.get(revision, key)?.let { + val flags = measureFlags(deferFlexibleWidth, deferFlexibleHeight, allowWidthOverflow, allowHeightOverflow) + val overrideWidth = widthOverride ?: Float.NaN + val overrideHeight = heightOverride ?: Float.NaN + cache.get(revision, availableWidth, availableHeight, overrideWidth, overrideHeight, flags, scrollbarReserves)?.let { onCacheHit() return it } @@ -121,7 +116,9 @@ private inline fun UiLayoutPipeline.measureNodeCached( deferFlexibleHeight, allowWidthOverflow, allowHeightOverflow, - ).also { cache.put(revision, key, it) } + ).also { + cache.put(revision, availableWidth, availableHeight, overrideWidth, overrideHeight, flags, scrollbarReserves, it) + } } private fun measureFlags( @@ -138,42 +135,73 @@ private fun measureFlags( return flags } -internal data class MeasureCacheKey( - val availableWidth: Float, - val availableHeight: Float, - val widthOverride: Float?, - val heightOverride: Float?, - val flags: Int, - val scrollbarReserves: Map, -) - +/** The last sizes of a node by what it was measured against; an override of NaN stands for none. */ internal class NodeMeasureCache { private var revision = Long.MIN_VALUE - private val keys = ArrayList(MaxEntries) - private val sizes = ArrayList(MaxEntries) + private val constraints = FloatArray(MaxEntries * ConstraintStride) + private val flags = IntArray(MaxEntries) + private val reserves = arrayOfNulls>(MaxEntries) + private val sizes = arrayOfNulls(MaxEntries) + private var count = 0 + private var cursor = 0 - fun get(revision: Long, key: MeasureCacheKey): LayoutSize? { + fun get( + revision: Long, + availableWidth: Float, + availableHeight: Float, + widthOverride: Float, + heightOverride: Float, + flags: Int, + reserves: Map, + ): LayoutSize? { if (this.revision != revision) return null - val index = keys.indexOf(key) - return if (index >= 0) sizes[index] else null + for (index in 0 until count) { + val offset = index * ConstraintStride + if (this.flags[index] == flags && + sameConstraint(constraints[offset], availableWidth) && + sameConstraint(constraints[offset + 1], availableHeight) && + sameConstraint(constraints[offset + 2], widthOverride) && + sameConstraint(constraints[offset + 3], heightOverride) && + this.reserves[index] == reserves + ) { + return sizes[index] + } + } + return null } - fun put(revision: Long, key: MeasureCacheKey, size: LayoutSize) { + fun put( + revision: Long, + availableWidth: Float, + availableHeight: Float, + widthOverride: Float, + heightOverride: Float, + flags: Int, + reserves: Map, + size: LayoutSize, + ) { if (this.revision != revision) { this.revision = revision - keys.clear() - sizes.clear() - } - if (keys.size >= MaxEntries) { - keys.removeAt(0) - sizes.removeAt(0) + count = 0 + cursor = 0 } - keys.add(key) - sizes.add(size) + val offset = cursor * ConstraintStride + constraints[offset] = availableWidth + constraints[offset + 1] = availableHeight + constraints[offset + 2] = widthOverride + constraints[offset + 3] = heightOverride + this.flags[cursor] = flags + this.reserves[cursor] = reserves + sizes[cursor] = size + cursor = (cursor + 1) % MaxEntries + if (count < MaxEntries) count++ } + private fun sameConstraint(cached: Float, value: Float) = cached == value || cached.isNaN() && value.isNaN() + private companion object { const val MaxEntries = 6 + const val ConstraintStride = 4 } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutModels.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutModels.kt index 7b64edb27..d1ca497da 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutModels.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutModels.kt @@ -8,6 +8,7 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiComputedStyle import ru.hollowhorizon.hollowengine.client.ui.style.layer import ru.hollowhorizon.hollowengine.client.ui.text.UiTextLayout import java.util.* +import kotlin.math.abs data class UiRect( val x: Float, @@ -43,6 +44,7 @@ data class UiLayoutNode( val scrollArea: UiRect = content, val textLayout: UiTextLayout? = null, val inlineDecoration: InlineGroupDecoration? = null, + val subtreeRevision: Long = 0L, ) data class UiLayoutResult( @@ -61,10 +63,16 @@ data class UiLayoutResult( internal fun snapshotVisibleChildren( layouts: Map, + known: Map> = emptyMap(), childrenOf: (UiNode) -> List = { node -> node.children }, ): Map> { val result = IdentityHashMap>() for (node in layouts.keys) { + val knownChildren = known[node] + if (knownChildren != null) { + result[node] = knownChildren + continue + } val source = childrenOf(node) if (source.isEmpty()) continue var visible: ArrayList? = null @@ -116,3 +124,10 @@ internal data class ResolvedUiInsets( val horizontal: Float get() = left + right val vertical: Float get() = top + bottom } + +/** Where the node is on the screen. */ +internal fun UiLayoutNode.shownRect(): UiRect { + val from = inputTransform.transform(0f, 0f) + val to = inputTransform.transform(rect.width, rect.height) + return UiRect(minOf(from.x, to.x), minOf(from.y, to.y), abs(to.x - from.x), abs(to.y - from.y)) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPass.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPass.kt deleted file mode 100644 index 1f078e209..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPass.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.layout - - -import ru.hollowhorizon.hollowengine.client.ui.UiNode -import java.util.* - -internal class LayoutPass(root: UiNode) { - val layoutChildren = IdentityHashMap>() - - init { - val stack = ArrayDeque() - stack.add(root) - while (stack.isNotEmpty()) { - val node = stack.removeLast() - val children = ArrayList(node.children) - layoutChildren[node] = children - for (index in children.indices.reversed()) { - stack.add(children[index]) - } - } - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPipeline.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPipeline.kt index 15e186c18..3078c8a18 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPipeline.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPipeline.kt @@ -5,52 +5,60 @@ import ru.hollowhorizon.hollowengine.client.ui.scroll.* import java.util.* class UiLayoutPipeline { - internal var layoutPass: LayoutPass? = null internal var activeProfile: UiProfileFrame? = null internal var measureDepth = 0 private val scrollbarCache = ScrollbarCache() - private val measureCache = WeakHashMap() private var lastScrollbarReserves: Map = emptyMap() internal val inlineFlowChildLayouts = HashMap>() internal val inlineFlowFlattened = HashSet() - internal fun measureCacheFor(node: UiNode): NodeMeasureCache = - measureCache.getOrPut(node) { NodeMeasureCache() } + /** The layout this pass may take unchanged subtrees from. */ + internal var reusableLayout: UiLayoutResult? = null + /** Child edges of the subtrees taken from [reusableLayout]. */ + internal val reusedChildren = IdentityHashMap>() + + /** + * Lays out the tree. [previous] lets a subtree that kept its revision and its place be taken as it was; it has + * to be a layout of the same root, viewport and scroll offsets. + */ fun compute( resolved: UiNode, width: Float, height: Float, scrollState: UiScrollState = UiScrollState(), profile: UiProfileFrame? = null, + previous: UiLayoutResult? = null, ): UiLayoutResult { val previousProfile = activeProfile activeProfile = profile try { val warmReserves = lastScrollbarReserves + reusableLayout = previous val warmLayout = computeLayouts(resolved, width, height, warmReserves) - val reserves = detectScrollbarReserves(warmLayout.nodes, warmLayout::childrenOf) + reusableLayout = null + val reserves = detectScrollbarReserves(warmLayout) { it.children } val finalLayout = if (reserves == warmReserves) { warmLayout } else { lastScrollbarReserves = reserves computeLayouts(resolved, width, height, reserves) } - applyScrollRanges(finalLayout.nodes, scrollState, finalLayout::childrenOf) - val scrollbars = placeScrollbarNodes(finalLayout.nodes, scrollbarCache) - val traversalOrder = finalLayout.nodes.keys.toList() - val childrenByNode = snapshotVisibleChildren(finalLayout.nodes) { node -> - finalLayout.children[node] ?: node.children - } + applyScrollRanges(finalLayout, scrollState) { it.children } + val scrollbars = placeScrollbarNodes(finalLayout, scrollbarCache) + val traversalOrder = finalLayout.keys.toList() + val childrenByNode = snapshotVisibleChildren(finalLayout, known = reusedChildren) return UiLayoutResult( root = resolved, - nodes = finalLayout.nodes, + nodes = finalLayout, traversalOrder = traversalOrder, scrollbars = scrollbars, childrenByNode = childrenByNode, ) } finally { + reusableLayout = null + reusedChildren.clear() activeProfile = previousProfile measureDepth = 0 } @@ -71,19 +79,12 @@ class UiLayoutPipeline { activeProfile = profile try { val layouts = LinkedHashMap(previous.nodes) - val pass = LayoutPass(resolved) - val previousPass = layoutPass - try { - layoutPass = pass - if (profile != null) { - profile.measurePasses++ - profile.placementPasses++ - profile.incrementalLayouts++ - } - for (container in moved) replaceScrolledSubtree(container, resolved, layouts) - } finally { - layoutPass = previousPass + if (profile != null) { + profile.measurePasses++ + profile.placementPasses++ + profile.incrementalLayouts++ } + for (container in moved) replaceScrolledSubtree(container, resolved, layouts) applyScrollRanges(layouts, scrollState) { node -> previous.childrenOf(node) } val scrollbars = placeScrollbarNodes(layouts, scrollbarCache) return UiLayoutResult( @@ -159,54 +160,38 @@ class UiLayoutPipeline { width: Float, height: Float, scrollbarReserves: Map, - ): LayoutComputation { + ): MutableMap { val layouts = linkedMapOf() val viewport = UiRect(0f, 0f, width, height) - val previousPass = layoutPass - val pass = LayoutPass(resolved) inlineFlowChildLayouts.clear() inlineFlowFlattened.clear() - try { - layoutPass = pass - val profile = activeProfile - if (profile != null) { - profile.measurePasses++ - profile.placementPasses++ - } - val rootRect = rootRect(resolved, width, height, scrollbarReserves) - val placementStartedAt = if (profile != null) System.nanoTime() else 0L - val measureBeforePlacement = profile?.measureNanos ?: 0L - placeNodeNow( - node = resolved, - resolved = resolved, - rect = rootRect, - parentRect = viewport, - parentClip = null, - parentTransform = UiMatrix4.identity(), - parentInputTransform = UiMatrix4.identity(), - insideFramebuffer = false, - scrollbarReserves = scrollbarReserves, - layouts = layouts, - ) - if (profile != null) { - val nestedMeasureNanos = profile.measureNanos - measureBeforePlacement - profile.placementNanos += (System.nanoTime() - placementStartedAt - nestedMeasureNanos).coerceAtLeast(0L) - } - } finally { - layoutPass = previousPass + reusedChildren.clear() + val profile = activeProfile + if (profile != null) { + profile.measurePasses++ + profile.placementPasses++ } - return LayoutComputation(layouts, pass.layoutChildren) - } - - internal fun layoutChildren(node: UiNode): List { - return layoutPass?.layoutChildren?.get(node) ?: node.children + val rootRect = rootRect(resolved, width, height, scrollbarReserves) + val placementStartedAt = if (profile != null) System.nanoTime() else 0L + val measureBeforePlacement = profile?.measureNanos ?: 0L + placeNodeNow( + node = resolved, + resolved = resolved, + rect = rootRect, + parentRect = viewport, + parentClip = null, + parentTransform = UiMatrix4.identity(), + parentInputTransform = UiMatrix4.identity(), + insideFramebuffer = false, + scrollbarReserves = scrollbarReserves, + layouts = layouts, + ) + if (profile != null) { + val nestedMeasureNanos = profile.measureNanos - measureBeforePlacement + profile.placementNanos += (System.nanoTime() - placementStartedAt - nestedMeasureNanos).coerceAtLeast(0L) + } + return layouts } -} - -private data class LayoutComputation( - val nodes: MutableMap, - val children: Map>, -) { - fun childrenOf(node: UiNode): List = children[node].orEmpty() + internal fun layoutChildren(node: UiNode): List = node.children } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPlacement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPlacement.kt index 032b6f3ca..c77c07cdf 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPlacement.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutPlacement.kt @@ -22,12 +22,6 @@ internal fun UiLayoutPipeline.placeNodeNow( profile.matrixCalculations++ } val style = resolved[node] - val boxes = nodeBoxes(rect, style, scrollbarReserves[node] ?: UiScrollbarReserve.None) - val scrollOffset = style.scroll?.state?.offset ?: UiScrollOffset.Zero - // Computed by the parent inline flow (wrapping depends on where the span starts in a line) - val textLayout = (node as? SpanNode)?.lineLayout - val overflowClip = overflowClipRect(rect, style, boxes.content) - val clip = if (style.clip || style.scrollable) parentClip.intersect(overflowClip) else parentClip val localX = rect.x - parentRect.x val localY = rect.y - parentRect.y val nodeTransform = style.transform @@ -49,6 +43,16 @@ internal fun UiLayoutPipeline.placeNodeNow( if (parentInputTransform === parentTransform) transform else parentInputTransform * localTransform } + val placed = reusableLayout?.nodes?.get(node) + if (placed != null && placed.isValidFor(node, rect, parentClip, transform, inputTransform, insideFramebuffer)) { + reusePlacedSubtree(node, reusableLayout!!, layouts) + return + } + val boxes = nodeBoxes(rect, style, scrollbarReserves[node] ?: UiScrollbarReserve.None) + val scrollOffset = style.scroll?.state?.offset ?: UiScrollOffset.Zero + val textLayout = (node as? SpanNode)?.lineLayout + val overflowClip = overflowClipRect(rect, style, boxes.content) + val clip = if (style.clip || style.scrollable) parentClip.intersect(overflowClip) else parentClip val opacityNeedsLayer = style.opacity < 1f && node.children.isNotEmpty() val needsFramebuffer = opacityNeedsLayer || nodeTransform.needsFramebuffer && !node.drawsInSpace() || @@ -76,6 +80,7 @@ internal fun UiLayoutPipeline.placeNodeNow( scrollArea = boxes.scrollArea, textLayout = textLayout, inlineDecoration = (node as? BaseUiNode)?.inlineDecoration, + subtreeRevision = node.layoutState.subtreeLayoutRevision, ) if (node.children.isEmpty()) return @@ -104,6 +109,37 @@ internal fun UiLayoutPipeline.placeNodeNow( ) } +/** + * Whether this placement of [node] still stands. Text is left out: where a span wraps comes from the flow around it, + * which a span or a nested flow cannot see from here. + */ +private fun UiLayoutNode.isValidFor( + node: UiNode, + rect: UiRect, + parentClip: UiRect?, + transform: UiMatrix4, + inputTransform: UiMatrix4, + insideFramebuffer: Boolean, +): Boolean = subtreeRevision == node.layoutState.subtreeLayoutRevision && + node !is SpanNode && + (node.measurePolicy as? UiBuiltInMeasurePolicy)?.kind != UiBuiltInMeasurePolicyKind.INLINE_FLOW && + this.rect == rect && + this.insideFramebuffer == insideFramebuffer && + outerClip == parentClip && + worldTransform.sameAs(transform) && + this.inputTransform.sameAs(inputTransform) + +private fun UiLayoutPipeline.reusePlacedSubtree( + node: UiNode, + previous: UiLayoutResult, + layouts: MutableMap, +) { + layouts[node] = previous[node] + val children = previous.childrenByNode[node] ?: return + reusedChildren[node] = children + for (index in children.indices) reusePlacedSubtree(children[index], previous, layouts) +} + private fun ChildPlacementScope.unscrolled(node: UiNode, rect: UiRect): UiRect { val pin = node.resolvedSnapshot.scrollPinned ?: return rect val offset = style.scroll?.state?.offset ?: return rect diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutState.kt index 3839dba7b..dfd0344bb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiLayoutState.kt @@ -179,6 +179,8 @@ class UiNodeLayoutState internal constructor() { */ internal var styleResolverState: Any? = null + internal var measureCache: NodeMeasureCache? = null + private var parent: UiNode? = null private var resolvedLayoutFingerprint = 0 private var localLayoutRevision = UiLayoutRevision.next() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/MinecraftUiRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/MinecraftUiRenderer.kt index 0b77bb3ef..e12588f92 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/MinecraftUiRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/MinecraftUiRenderer.kt @@ -110,7 +110,7 @@ internal fun UiRenderCommand.isSegmentBatchable(): Boolean = when (this) { is DrawImageCommand -> !renderToFramebuffer && filter == UiFilterChain.Empty is DrawParticlesCommand -> filter == UiFilterChain.Empty is DrawTextCommand -> true - is PushClipCommand, is PopClipCommand -> true + is PushClipCommand, is PopClipCommand, is CompositeLayerCommand -> true else -> false } @@ -170,7 +170,6 @@ class MinecraftUiRenderer { private val phaseClipStack = ArrayDeque() private val segmentBaseClips = ArrayList() private var phaseClip: UiRect? = null - private var pathTileBatchClip: UiRect? = null private var shapeBatchClip: UiRect? = null private var imageBatchClip: UiRect? = null private var textBatchClip: UiRect? = null @@ -189,8 +188,17 @@ class MinecraftUiRenderer { } } + /** The batches a frame collects, in the order they are drawn in whenever several of them are pending. */ private enum class UiBatchKind { ANALYTIC_RECT, PATH_TILE, SHAPE, IMAGE, TEXT } + private fun boundsOf(kind: UiBatchKind) = when (kind) { + UiBatchKind.ANALYTIC_RECT -> analyticBatchBounds + UiBatchKind.PATH_TILE -> pathTileBatchBounds + UiBatchKind.SHAPE -> shapeBatchBounds + UiBatchKind.IMAGE -> imageBatchBounds + UiBatchKind.TEXT -> textBatchBounds + } + /** Screen-space AABB of a (0,0,width,height) quad under [transform] into quadMin/Max fields. */ private fun computeQuadBounds(width: Float, height: Float, transform: UiMatrix4, padding: Float = 0f) { if (transform.isAxisAligned) { @@ -220,23 +228,23 @@ class MinecraftUiRenderer { quadMaxY <= clip.y || quadMinY >= clip.y + clip.height } - /** Flushes every pending batch (except [except]) whose pixels the current quad overlaps. */ - private fun flushBatchesOverlappingQuad(except: UiBatchKind?) { - if (except != UiBatchKind.ANALYTIC_RECT && - analyticBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) - ) flushAnalyticRectBatch() - if (except != UiBatchKind.PATH_TILE && - pathTileBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) - ) flushPathTileBatch() - if (except != UiBatchKind.SHAPE && - shapeBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) - ) flushShapeBatch() - if (except != UiBatchKind.IMAGE && - imageBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) - ) flushPhaseImageBatches() - if (except != UiBatchKind.TEXT && - textBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) - ) flushTextBatch() + private fun flushBatchesOverlappingQuad(kind: UiBatchKind?) { + val kinds = UiBatchKind.entries + for (index in kinds.lastIndex downTo (kind?.ordinal ?: -1) + 1) { + if (boundsOf(kinds[index]).overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY)) return flushThrough(kinds[index]) + } + } + + /** Draws what is pending in the batches up to [kind], in their order. */ + private fun flushThrough(kind: UiBatchKind) { + flushAnalyticRectBatch() + if (kind >= UiBatchKind.PATH_TILE) flushPathTileBatch() + if (kind >= UiBatchKind.SHAPE) flushShapeBatch() + if (kind >= UiBatchKind.IMAGE) flushPhaseImageBatches() + if (kind == UiBatchKind.TEXT) { + textBatchBounds.clear() + flushMsdfTextBatch() + } } /** Renders directly unless framebuffer layers require a collected atlas pre-pass. */ @@ -297,6 +305,7 @@ class MinecraftUiRenderer { } private fun submit(command: UiRenderCommand) { + if (command is FlushBarrierCommand && analyticRectRenderer.isAvailable) return if (command.isSegmentBatchable()) { segment += command when (command) { @@ -313,8 +322,7 @@ class MinecraftUiRenderer { renderSegment(segment) clearSegment() render(command) - flushGeometryBatches() - flushTextBatch() + flushThrough(UiBatchKind.TEXT) } private fun clearSegment() { @@ -351,6 +359,7 @@ class MinecraftUiRenderer { is DrawItemCommand -> drawItem(command) is DrawEntityCommand -> drawEntity(command) is DrawCanvasGlCommand -> drawCanvasGl(command) + is CompositeLayerCommand -> compositePreparedLayer(command.node, clipStack.lastOrNull()) } } @@ -369,9 +378,7 @@ class MinecraftUiRenderer { preparingLayerAtlas = false clipStack.clear() phaseClipStack.clear() - flushPhaseImageBatches() - flushGeometryBatches() - flushTextBatch() + flushThrough(UiBatchKind.TEXT) restoreMainProjection() } } @@ -380,17 +387,33 @@ class MinecraftUiRenderer { var index = 0 for (layer in plan.layers) { while (index < layer.startIndex) submit(plan.commands[index++]) - renderSegment(segment) - clearSegment() - compositePreparedLayer(layer.command.node) + if (analyticRectRenderer.isAvailable) { + submit(CompositeLayerCommand(layer.command.node)) + } else { + renderSegment(segment) + clearSegment() + compositePreparedLayer(layer.command.node, clipStack.lastOrNull()) + } index = layer.endIndex + 1 } while (index < plan.commands.size) submit(plan.commands[index++]) } - private fun compositePreparedLayer(node: UiNode) { + /** Draws a prepared layer between the batches around it: only what lies under the layer has to go first. */ + private fun compositePhaseLayer(node: UiNode) { + val layer = preparedLayers[node]?.layer ?: return + computeQuadBounds( + layer.rect.width + layer.padding * 2f, + layer.rect.height + layer.padding * 2f, + calculateRootTransform(layer), + ) + flushBatchesOverlappingQuad(null) + compositePreparedLayer(node, phaseClip) + } + + private fun compositePreparedLayer(node: UiNode, clip: UiRect?) { val prepared = preparedLayers.remove(node) ?: return - setScissor(clipStack.lastOrNull()) + setScissor(clip) val transform = calculateRootTransform(prepared.layer) drawLayerTexture( prepared.layer, @@ -418,13 +441,15 @@ class MinecraftUiRenderer { if (commands.isEmpty()) return segmentBaseClips.clear() segmentBaseClips.addAll(clipStack) - for (phase in UiRenderPhase.entries) { - if (segmentPhases[phase.ordinal]) renderPhase(commands, phase) + if (analyticRectRenderer.isAvailable) { + renderPhase(commands, null) + } else { + for (phase in UiRenderPhase.entries) { + if (segmentPhases[phase.ordinal]) renderPhase(commands, phase) + } } commitSegmentClips(commands) - flushPhaseImageBatches() - flushGeometryBatches() - flushTextBatch() + flushThrough(UiBatchKind.TEXT) setScissor(clipStack.lastOrNull()) } @@ -445,7 +470,11 @@ class MinecraftUiRenderer { } } - private fun renderPhase(commands: List, phase: UiRenderPhase) { + /** + * Draws the commands of [phase]. Rects and text share a batch that keeps their order, so with null all of them go + * in one pass as the tree lays them out; phases are only for drawing text apart from what is under it. + */ + private fun renderPhase(commands: List, phase: UiRenderPhase?) { phaseClipStack.clear() phaseClipStack.addAll(segmentBaseClips) phaseClip = phaseClipStack.lastOrNull() @@ -463,7 +492,7 @@ class MinecraftUiRenderer { phaseClip = phaseClipStack.lastOrNull() } - is DrawShadowCommand -> if (phase == UiRenderPhase.BACKGROUND) { + is DrawShadowCommand -> if (phase == null || phase == UiRenderPhase.BACKGROUND) { if (!appendShapeShadows(command)) { var padding = 0f for (shadow in command.shadows) { @@ -476,53 +505,55 @@ class MinecraftUiRenderer { computeQuadBounds( command.rect.width, command.rect.height, effective(command.transform), padding, ) - flushBatchesOverlappingQuad(except = null) + flushBatchesOverlappingQuad(null) setScissor(phaseClip) drawShadow(command) } } - is DrawBoxCommand -> if (command.phase == phase) { + is DrawBoxCommand -> if (phase == null || command.phase == phase) { if (!appendAnalyticRect(command) && !appendBatchedShapes(command)) { computeQuadBounds(command.rect.width, command.rect.height, effective(command.transform)) - flushBatchesOverlappingQuad(except = null) + flushBatchesOverlappingQuad(null) setScissor(phaseClip) drawBox(command) } } - is DrawShapeCommand -> if (command.phase == phase) { + is DrawShapeCommand -> if (phase == null || command.phase == phase) { if (!appendPathTile(command) && !appendBatchedShapes(command)) { computeQuadBounds(command.rect.width, command.rect.height, effective(command.transform)) - flushBatchesOverlappingQuad(except = null) + flushBatchesOverlappingQuad(null) setScissor(phaseClip) drawShape(command) } } - is DrawImageCommand -> if (command.phase == phase) { + is DrawImageCommand -> if (phase == null || command.phase == phase) { appendPhaseImage(command) } - is DrawParticlesCommand -> if (command.phase == phase) { + is DrawParticlesCommand -> if (phase == null || command.phase == phase) { appendPhaseParticles(command) } - is DrawRawTextureCommand -> if (command.phase == phase) { + is DrawRawTextureCommand -> if (phase == null || command.phase == phase) { computeQuadBounds(command.rect.width, command.rect.height, effective(command.transform)) - flushBatchesOverlappingQuad(except = null) + flushBatchesOverlappingQuad(null) setScissor(phaseClip) drawRawTexture(command) } - is DrawTextCommand -> if (command.phase == phase) { + is DrawTextCommand -> if (phase == null || command.phase == phase) { appendPhaseText(command) } + is CompositeLayerCommand -> compositePhaseLayer(command.node) + else -> Unit } } - flushPhaseImageBatches() + flushThrough(UiBatchKind.IMAGE) phaseClip = phaseClipStack.lastOrNull() } @@ -533,7 +564,7 @@ class MinecraftUiRenderer { flushBatchesOverlappingQuad(UiBatchKind.IMAGE) val overlapsPendingImages = imageBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) if (phaseImageBatches.isNotEmpty() && (imageBatchClip != phaseClip || overlapsPendingImages)) { - flushPhaseImageBatches() + flushThrough(UiBatchKind.IMAGE) } if (!appendSvgImage(command, phaseImageBatches)) appendImageBatch(command, phaseImageBatches) imageBatchClip = phaseClip @@ -549,7 +580,7 @@ class MinecraftUiRenderer { val overlapsOtherTextures = phaseImageBatches.keys.any { it != TextureAtlas.LOCATION_PARTICLES } && imageBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) if (phaseImageBatches.isNotEmpty() && (imageBatchClip != phaseClip || overlapsOtherTextures)) { - flushPhaseImageBatches() + flushThrough(UiBatchKind.IMAGE) } appendParticleQuads(command, transform, phaseImageBatches) imageBatchClip = phaseClip @@ -578,7 +609,7 @@ class MinecraftUiRenderer { analyticBatchBounds.add(quadMinX, quadMinY, quadMaxX, quadMaxY) } else { flushBatchesOverlappingQuad(UiBatchKind.TEXT) - if (!msdfTextBatch.isEmpty && textBatchClip != phaseClip) flushMsdfTextBatch() + if (!msdfTextBatch.isEmpty && textBatchClip != phaseClip) flushThrough(UiBatchKind.TEXT) textBatchClip = phaseClip clipStack.clear() clipStack.addAll(phaseClipStack) @@ -720,9 +751,16 @@ class MinecraftUiRenderer { } /** The active phase clip as a shader-space rect (or [UiShaderClip.None] when unclipped). */ + private var phaseShaderClipSource: UiRect? = null + private var phaseShaderClipValue = UiShaderClip.None + private fun phaseShaderClip(): UiShaderClip { val clip = phaseClip ?: return UiShaderClip.None - return UiShaderClip(clip.x, clip.y, clip.x + clip.width, clip.y + clip.height) + if (clip !== phaseShaderClipSource) { + phaseShaderClipSource = clip + phaseShaderClipValue = UiShaderClip(clip.x, clip.y, clip.x + clip.width, clip.y + clip.height) + } + return phaseShaderClipValue } /** @@ -746,19 +784,13 @@ class MinecraftUiRenderer { private fun flushPathTileBatch() { pathTileBatchBounds.clear() if (!pathTileBatch.isEmpty) { - setScissor(pathTileBatchClip) + setScissor(null) activeProfile?.pathTileDraws++ pathTileRenderer.draw(pathTileBatch) } pathTileBatch.clear() } - private fun flushGeometryBatches() { - flushAnalyticRectBatch() - flushPathTileBatch() - flushShapeBatch() - } - private fun flushMsdfTextBatch() { if (msdfTextBatch.isEmpty) return setScissor(textBatchClip) @@ -766,11 +798,7 @@ class MinecraftUiRenderer { msdfTextBatch.flush() } - private fun flushTextBatch() { - textBatchBounds.clear() - flushAnalyticRectBatch() - flushMsdfTextBatch() - } + private fun flushTextBatch() = flushThrough(UiBatchKind.TEXT) private fun appendBatchedShapes(command: UiRenderCommand): Boolean { if (command is DrawShapeCommand) return appendBatchedShape(command) @@ -786,7 +814,7 @@ class MinecraftUiRenderer { computeQuadBounds(command.rect.width, command.rect.height, transform) if (quadFullyClipped()) return true flushBatchesOverlappingQuad(UiBatchKind.SHAPE) - if (!shapeBatch.isEmpty && shapeBatchClip != phaseClip) flushShapeBatch() + if (!shapeBatch.isEmpty && shapeBatchClip != phaseClip) flushThrough(UiBatchKind.SHAPE) shapeBatchClip = phaseClip shapeBatchBounds.add(quadMinX, quadMinY, quadMaxX, quadMaxY) when (val paint = command.paint) { @@ -841,7 +869,7 @@ class MinecraftUiRenderer { computeQuadBounds(command.rect.width, command.rect.height, transform, padding) if (quadFullyClipped()) return true flushBatchesOverlappingQuad(UiBatchKind.SHAPE) - if (!shapeBatch.isEmpty && shapeBatchClip != phaseClip) flushShapeBatch() + if (!shapeBatch.isEmpty && shapeBatchClip != phaseClip) flushThrough(UiBatchKind.SHAPE) shapeBatchClip = phaseClip shapeBatchBounds.add(quadMinX, quadMinY, quadMaxX, quadMaxY) return shapeBatch.appendLocalShape( @@ -888,9 +916,7 @@ class MinecraftUiRenderer { computeQuadBounds(command.rect.width, command.rect.height, transform, padding) if (quadFullyClipped()) return true flushBatchesOverlappingQuad(UiBatchKind.PATH_TILE) - if (!pathTileBatch.isEmpty && pathTileBatchClip != phaseClip) flushPathTileBatch() - pathTileBatch.append(command, transform) - pathTileBatchClip = phaseClip + pathTileBatch.append(command, transform, phaseShaderClip()) pathTileBatchBounds.add(quadMinX, quadMinY, quadMaxX, quadMaxY) return true } @@ -910,29 +936,24 @@ class MinecraftUiRenderer { val scale = layerScale() layerRequests.clear() layerPaddings.clear() - val overflows = layout.layerOverflows() - for (node in layout.traversalOrder) { - val layoutNode = layout.nodes[node] ?: continue + for (layoutNode in layout.nodes.values) { if (!layoutNode.needsFramebuffer) continue - val padding = layerPadding(node.resolvedSnapshot.filter, overflows[node] ?: 0f) + val node = layoutNode.node + val padding = layerPadding(node.resolvedSnapshot.filter, layout.layerOverflow(node)) layerPaddings[node] = padding - val layerScale = scale * transformResolutionScale(layoutNode.worldTransform, resolutionScratch) + val layerScale = scale * layerResolutionScale(layoutNode.worldTransform, resolutionScratch) val width = ceil((layoutNode.rect.width + padding * 2f) * layerScale).toInt().coerceAtLeast(1) val height = ceil((layoutNode.rect.height + padding * 2f) * layerScale).toInt().coerceAtLeast(1) layerRequests += UiLayerRequest(width, height) } - framebuffers.beginFrame( - layerRequests, - 1, - 1, - ) + framebuffers.beginFrame(layerRequests) return layerRequests.isNotEmpty() } private fun beginLayer(command: BeginLayerCommand) { scissorState = ScissorUnknown // Must match prepareFramebuffers, which sized the atlas this layer is allocated from. - val resolution = transformResolutionScale(command.transform, resolutionScratch) + val resolution = layerResolutionScale(command.transform, resolutionScratch) val scale = layerScale() * resolution val padding = layerPaddings[command.node] ?: layerPadding(command) val logicalWidth = command.rect.width + padding * 2f @@ -1280,7 +1301,7 @@ class MinecraftUiRenderer { private fun drawShadow(command: DrawShadowCommand) { if (command.image != null) { appendImageShadows(command) - flushPhaseImageBatches() + flushThrough(UiBatchKind.IMAGE) return } val transform = effective(command.transform) @@ -1346,7 +1367,7 @@ class MinecraftUiRenderer { val cacheImage = image.copy(tintAlpha = 1f) for (shadow in command.shadows) { if (shadow.color.alpha <= 0f) continue - val transform = effective(UiMatrix4.translation(shadow.offset.x, shadow.offset.y, shadow.offset.z) * command.transform) + val transform = effective(command.transform.shifted(shadow.offset.x, shadow.offset.y, shadow.offset.z)) if (isBackfaceHidden(command.rect.width, command.rect.height, transform, command.backfaceVisibility)) continue val shadowBounds = imageShadowBounds(bounds, shadow.blur, shadow.spread) val shadowTransform = transform.translated(shadowBounds.x, shadowBounds.y) @@ -1369,7 +1390,7 @@ class MinecraftUiRenderer { } val overlapsOtherTextures = phaseImageBatches.keys.any { it != mask.texture } && imageBatchBounds.overlaps(quadMinX, quadMinY, quadMaxX, quadMaxY) - if (phaseImageBatches.isNotEmpty() && (imageBatchClip != phaseClip || overlapsOtherTextures)) flushPhaseImageBatches() + if (phaseImageBatches.isNotEmpty() && (imageBatchClip != phaseClip || overlapsOtherTextures)) flushThrough(UiBatchKind.IMAGE) phaseImageBatches.getOrPut(mask.texture) { ArrayList() } += UiTexturedQuad( mask.bounds.width, mask.bounds.height, shadowTransform, command.opacity * image.tintAlpha, flipY = true, tint = shadow.color.filtered(command.filter), alphaMask = true, @@ -1392,7 +1413,7 @@ class MinecraftUiRenderer { val transform = if (shadow.inset) { effective(command.transform) } else { - effective(UiMatrix4.translation(shadow.offset.x, shadow.offset.y, shadow.offset.z) * command.transform) + effective(command.transform.shifted(shadow.offset.x, shadow.offset.y, shadow.offset.z)) } if (isBackfaceHidden( command.rect.width, @@ -1401,9 +1422,15 @@ class MinecraftUiRenderer { command.backfaceVisibility, ) ) continue - val padding = if (shadow.inset) 0f else abs(shadow.spread) + shadow.blur.coerceAtLeast(0f) * 3f + 1f + if (shadow.color.alpha <= 0f) continue + val blur = shadow.blur.coerceAtLeast(0f) + val spread = if (shadow.inset) 0f else abs(shadow.spread) + val padding = if (shadow.inset) 0f else spread + blur * 3f + 1f computeQuadBounds(command.rect.width, command.rect.height, transform, padding) + if (quadFullyClipped()) continue + computeQuadBounds(command.rect.width, command.rect.height, transform, if (shadow.inset) 0f else spread + blur * VisibleShadowBlurs) flushBatchesOverlappingQuad(UiBatchKind.ANALYTIC_RECT) + computeQuadBounds(command.rect.width, command.rect.height, transform, padding) analyticRectBatch.appendShadow(command, shadow, transform, phaseShaderClip()) analyticBatchBounds.add(quadMinX, quadMinY, quadMaxX, quadMaxY) } @@ -1505,7 +1532,7 @@ class MinecraftUiRenderer { private fun drawShape(command: DrawShapeCommand) { if (appendBatchedShape(command)) { - flushShapeBatch() + flushThrough(UiBatchKind.SHAPE) return } val transform = effective(command.transform) @@ -1539,10 +1566,9 @@ class MinecraftUiRenderer { clipped -> phaseShaderClip().intersect(localViewportClip(command.rect, transform)) else -> phaseShaderClip() } - if (clipped) { - flushPhaseImageBatches() - flushGeometryBatches() - flushTextBatch() + val scissored = clipped && (!useAnalytic || command.layout.hasInlineImages()) + if (scissored) { + flushThrough(UiBatchKind.TEXT) pushClip(UiRect(0f, 0f, command.rect.width, command.rect.height), command.transform) textBatchClip = clipStack.lastOrNull() } @@ -1554,7 +1580,7 @@ class MinecraftUiRenderer { ) else line drawTextLine(command, displayLine, transform, now) } - if (clipped) { + if (scissored) { flushTextBatch() popClip() textBatchClip = phaseClip @@ -2531,29 +2557,48 @@ class MinecraftUiRenderer { private data object ScissorUnknown /** Accumulated screen-space AABB of a batch's pending, not-yet-flushed pixels. */ -private class UiBatchBounds { - private var minX = Float.POSITIVE_INFINITY - private var minY = Float.POSITIVE_INFINITY - private var maxX = Float.NEGATIVE_INFINITY - private var maxY = Float.NEGATIVE_INFINITY +/** + * Where the quads of a pending batch are, one rect after another: a quad falling between them has nothing to wait for. + * Past [MaxRects] the last rect grows over the rest, which can only flush more often than needed. + */ +/** How many blur radii out a shadow still shows: past that it is too faint to keep neighbors out of a batch for. */ +private const val VisibleShadowBlurs = 1.5f + +internal class UiBatchBounds { + private val rects = FloatArray(MaxRects * RectStride) + private var count = 0 fun clear() { - minX = Float.POSITIVE_INFINITY - minY = Float.POSITIVE_INFINITY - maxX = Float.NEGATIVE_INFINITY - maxY = Float.NEGATIVE_INFINITY + count = 0 } fun add(minX: Float, minY: Float, maxX: Float, maxY: Float) { - if (minX < this.minX) this.minX = minX - if (minY < this.minY) this.minY = minY - if (maxX > this.maxX) this.maxX = maxX - if (maxY > this.maxY) this.maxY = maxY + if (count == MaxRects) { + val last = (count - 1) * RectStride + rects[last] = min(rects[last], minX) + rects[last + 1] = min(rects[last + 1], minY) + rects[last + 2] = max(rects[last + 2], maxX) + rects[last + 3] = max(rects[last + 3], maxY) + return + } + val at = count++ * RectStride + rects[at] = minX + rects[at + 1] = minY + rects[at + 2] = maxX + rects[at + 3] = maxY } fun overlaps(minX: Float, minY: Float, maxX: Float, maxY: Float): Boolean { - if (this.minX > this.maxX) return false - return this.minX < maxX && minX < this.maxX && this.minY < maxY && minY < this.maxY + for (rect in 0 until count) { + val at = rect * RectStride + if (rects[at] < maxX && minX < rects[at + 2] && rects[at + 1] < maxY && minY < rects[at + 3]) return true + } + return false + } + + private companion object { + const val MaxRects = 256 + const val RectStride = 4 } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiFramebufferPool.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiFramebufferPool.kt index 54aa5bf8a..0baa5fdb7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiFramebufferPool.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiFramebufferPool.kt @@ -10,6 +10,10 @@ import kotlin.math.min private const val BackdropWorkspaceGrowthLimit = 256 private const val BackdropWorkspaceAlignment = 64 private const val BackdropWorkspaceShrinkFrames = 300 +private const val AtlasGrowthStep = 128 +private const val AtlasShrinkFrames = 300 +private const val AtlasShrinkRatio = 4L +private const val AtlasKeptArea = 1L shl 20 internal fun alignFramebufferAxis(required: Int, maxTextureSize: Int): Int { require(required > 0 && maxTextureSize > 0) @@ -31,43 +35,96 @@ internal fun growFramebufferAxis(required: Int, current: Int, maxTextureSize: In return alignFramebufferAxis(grown, maxTextureSize) } +/** The size to give an atlas for [layerRequests]: at least [current], with little more room than they take. */ +internal fun atlasSizeFor(layerRequests: List, current: UiAtlasSize?, maxTextureSize: Int): UiAtlasSize { + var requiredWidth = current?.width ?: 1 + var requiredHeight = current?.height ?: 1 + layerRequests.forEach { + requiredWidth = max(requiredWidth, it.width + UiAtlasGuardPixels * 2) + requiredHeight = max(requiredHeight, it.height + UiAtlasGuardPixels * 2) + } + var width = growFramebufferAxis(requiredWidth.coerceAtMost(maxTextureSize), current?.width ?: 0, maxTextureSize) + var height = growFramebufferAxis(requiredHeight.coerceAtMost(maxTextureSize), current?.height ?: 0, maxTextureSize) + while (!layersFit(layerRequests, width, height)) { + val canGrowWidth = width < maxTextureSize + val canGrowHeight = height < maxTextureSize + if (!canGrowWidth && !canGrowHeight) break + if (width <= height && canGrowWidth || !canGrowHeight) { + width = min(width + AtlasGrowthStep, maxTextureSize) + } else { + height = min(height + AtlasGrowthStep, maxTextureSize) + } + } + return UiAtlasSize(width, height) +} + +/** Whether an atlas of [width]x[height] holds several times more than [layerRequests] need. */ +internal fun isAtlasOversized(layerRequests: List, width: Int, height: Int): Boolean { + val area = width.toLong() * height + val used = layerRequests.sumOf { + (it.width + UiAtlasGuardPixels * 2L) * (it.height + UiAtlasGuardPixels * 2L) + } + return area > AtlasKeptArea && used * AtlasShrinkRatio < area +} + +private fun layersFit(layerRequests: List, width: Int, height: Int): Boolean { + val testAllocator = UiAtlasAllocator(width, height) + return layerRequests.all { + testAllocator.allocate( + it.width + UiAtlasGuardPixels * 2, + it.height + UiAtlasGuardPixels * 2, + ) != null + } +} + internal class UiFramebufferPool { private val scratchFramebuffers = mutableListOf() private val retiredAtlases = mutableListOf() private var atlas: UiFramebuffer? = null private var allocator = UiAtlasAllocator(1, 1) - private var atlasBaseWidth = 0 - private var atlasBaseHeight = 0 + private var atlasOversizedFrames = 0 private var backdropWorkspace: UiBackdropBlurWorkspace? = null private var backdropRequestedWidth = 0 private var backdropRequestedHeight = 0 private var backdropUnderusedFrames = 0 - fun beginFrame(layerRequests: List, minimumWidth: Int, minimumHeight: Int) { + fun beginFrame(layerRequests: List) { retireOldAtlases() maintainBackdropWorkspace() + val shrink = shouldShrinkAtlas(layerRequests) if (layerRequests.isEmpty()) { allocator = UiAtlasAllocator(1, 1) + if (shrink) replaceAtlas(null) return } - val width = minimumWidth.coerceAtLeast(1) - val height = minimumHeight.coerceAtLeast(1) val current = atlas - val windowChanged = width != atlasBaseWidth || height != atlasBaseHeight - val size = if (current != null && !windowChanged && fits(layerRequests, current.width, current.height)) { + val size = if (current != null && !shrink && layersFit(layerRequests, current.width, current.height)) { UiAtlasSize(current.width, current.height) } else { - atlasSizeFor(layerRequests, width, height, if (windowChanged) null else current) + val kept = current?.takeUnless { shrink }?.let { UiAtlasSize(it.width, it.height) } + atlasSizeFor(layerRequests, kept, GL11.glGetInteger(GL11.GL_MAX_TEXTURE_SIZE).coerceAtLeast(1)) } if (current == null || current.width != size.width || current.height != size.height) { - current?.let { retiredAtlases += RetiredUiFramebuffer(it) } - atlas = UiFramebuffer(size.width, size.height) + replaceAtlas(UiFramebuffer(size.width, size.height)) } - atlasBaseWidth = width - atlasBaseHeight = height allocator = UiAtlasAllocator(size.width, size.height) } + /** True once the atlas has stayed oversized for a while, so that one big layer does not hold it for good. */ + private fun shouldShrinkAtlas(layerRequests: List): Boolean { + val current = atlas + val oversized = current != null && isAtlasOversized(layerRequests, current.width, current.height) + atlasOversizedFrames = if (oversized) atlasOversizedFrames + 1 else 0 + if (atlasOversizedFrames < AtlasShrinkFrames) return false + atlasOversizedFrames = 0 + return true + } + + private fun replaceAtlas(next: UiFramebuffer?) { + atlas?.let { retiredAtlases += RetiredUiFramebuffer(it) } + atlas = next + } + fun acquireLayer(width: Int, height: Int): UiLayerFramebuffer { if (atlas == null) { val atlasWidth = width + UiAtlasGuardPixels * 2 @@ -128,8 +185,7 @@ internal class UiFramebufferPool { fun close() { atlas?.close() atlas = null - atlasBaseWidth = 0 - atlasBaseHeight = 0 + atlasOversizedFrames = 0 backdropWorkspace?.close() backdropWorkspace = null backdropRequestedWidth = 0 @@ -189,44 +245,6 @@ internal class UiFramebufferPool { ).also { backdropWorkspace = it } } - private fun atlasSizeFor( - layerRequests: List, - minimumWidth: Int, - minimumHeight: Int, - current: UiFramebuffer?, - ): UiAtlasSize { - val maxTextureSize = GL11.glGetInteger(GL11.GL_MAX_TEXTURE_SIZE).coerceAtLeast(1) - var width = max(minimumWidth, current?.width ?: 0).coerceAtMost(maxTextureSize) - var height = max(minimumHeight, current?.height ?: 0).coerceAtMost(maxTextureSize) - - layerRequests.forEach { - width = max(width, it.width + UiAtlasGuardPixels * 2).coerceAtMost(maxTextureSize) - height = max(height, it.height + UiAtlasGuardPixels * 2).coerceAtMost(maxTextureSize) - } - - while (!fits(layerRequests, width, height)) { - val canGrowWidth = width < maxTextureSize - val canGrowHeight = height < maxTextureSize - if (!canGrowWidth && !canGrowHeight) break - if (width <= height && canGrowWidth || !canGrowHeight) { - width = min(width * 2, maxTextureSize) - } else { - height = min(height * 2, maxTextureSize) - } - } - - return UiAtlasSize(reserveAtlasAxis(width, maxTextureSize), reserveAtlasAxis(height, maxTextureSize)) - } - - private fun fits(layerRequests: List, width: Int, height: Int): Boolean { - val testAllocator = UiAtlasAllocator(width, height) - return layerRequests.all { - testAllocator.allocate( - it.width + UiAtlasGuardPixels * 2, - it.height + UiAtlasGuardPixels * 2, - ) != null - } - } } private class RetiredUiFramebuffer( @@ -425,15 +443,10 @@ private data class UiPackedRect( other.y + other.height <= y + height } -private data class UiAtlasSize( +internal data class UiAtlasSize( val width: Int, val height: Int, ) -private fun reserveAtlasAxis(value: Int, maxTextureSize: Int): Int = - min(max(value * UiAtlasGrowthMultiplier, value + UiAtlasGrowthReservePixels), maxTextureSize) - private const val UiAtlasGuardPixels = 2 -private const val UiAtlasGrowthMultiplier = 2 -private const val UiAtlasGrowthReservePixels = 512 private const val RetiredAtlasFrames = 3 diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatch.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatch.kt index 6970cd2a4..d4faf8c5f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatch.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatch.kt @@ -23,6 +23,9 @@ internal class UiPathTileBatch { private val paints = UiFloatArrayBuilder() private val stops = UiFloatArrayBuilder() private val paintEncoder = UiPaintBufferEncoder(paints, stops) + + /** The clip of each paint record, under the same index: a tile reaches both through its paint. */ + private val clips = UiFloatArrayBuilder() private val vertices = UiFloatArrayBuilder() private val segmentIndices = UiIntArrayBuilder() private val tiles = UiIntArrayBuilder() @@ -45,6 +48,7 @@ internal class UiPathTileBatch { val segmentFloatCount: Int get() = segments.size val computeInputFloatCount: Int get() = paths.size + candidates.size val paintFloatCount: Int get() = paints.size + val clipFloatCount: Int get() = clips.size val stopFloatCount: Int get() = stops.size val vertexCount: Int get() = vertices.size / VertexStride val vertexFloatCount: Int get() = vertices.size @@ -57,7 +61,11 @@ internal class UiPathTileBatch { val canDispatchCompute: Boolean get() = candidateCount <= MaxComputeCandidates && worstCaseSegmentIndexCount <= MaxComputeSegmentIndices - val prefersBakedCpu: Boolean get() = bakedCpuPreferred + /** + * Whether to skip the compute pass. Bakes make it free for repeated paths, and a batch this small costs less to + * rasterize here than a dispatch with its barriers and bindings does, even when every key in it is new. + */ + val prefersBakedCpu: Boolean get() = bakedCpuPreferred || candidateCount <= MaxCpuCandidates val fullCoverageTileCount: Int get() { var count = 0 @@ -88,7 +96,7 @@ internal class UiPathTileBatch { return true } - fun append(command: DrawShapeCommand, transform: UiMatrix4) { + fun append(command: DrawShapeCommand, transform: UiMatrix4, clip: UiShaderClip = UiShaderClip.None) { val width = command.rect.width val height = command.rect.height if (width <= 0f || height <= 0f || command.opacity <= 0f) return @@ -112,6 +120,7 @@ internal class UiPathTileBatch { 0f, command.blurRadius, command.spreadRadius, + clip, ) } if (command.stroke.isBufferPaint() && command.strokeWidth > 0f) { @@ -138,6 +147,7 @@ internal class UiPathTileBatch { if (analyticStroke) command.strokeWidth * 0.5f else 0f, command.blurRadius, command.spreadRadius, + clip, ) } } @@ -154,6 +164,7 @@ internal class UiPathTileBatch { strokeRadius: Float, blurRadius: Float, spreadRadius: Float, + clip: UiShaderClip, ) { if (geometry.segments.isEmpty()) return val rasterMargin = aaMargin + strokeRadius + abs(spreadRadius) + blurRadius * BlurExtentFactor @@ -174,6 +185,7 @@ internal class UiPathTileBatch { width, height, ) + clips.add(clip.minX, clip.minY, clip.maxX, clip.maxY) val pathIndex = pathCount paths.add(segmentStart.toFloat(), pathSegmentCount.toFloat(), paintIndex.toFloat(), tileSize) paths.add(width, height, rasterMargin, minTileX.toFloat()) @@ -244,6 +256,7 @@ internal class UiPathTileBatch { paths.clear() candidates.clear() paints.clear() + clips.clear() stops.clear() vertices.clear() segmentIndices.clear() @@ -261,6 +274,7 @@ internal class UiPathTileBatch { for (index in 0 until candidates.size) destination.put(candidates[index].toFloat()) } fun writePaints(destination: FloatBuffer) = paints.writeTo(destination) + fun writeClips(destination: FloatBuffer) = clips.writeTo(destination) fun writeStops(destination: FloatBuffer) = stops.writeTo(destination) fun writeVertices(destination: FloatBuffer) = vertices.writeTo(destination) fun writeSegmentIndices(destination: IntBuffer) = segmentIndices.writeTo(destination) @@ -313,6 +327,7 @@ internal class UiPathTileBatch { tileSize, paintIndex, rowSegmentCount, + aaMargin, strokeRadius, blurRadius, spreadRadius, @@ -329,13 +344,15 @@ internal class UiPathTileBatch { tileSize: Float, paintIndex: Int, rowSegmentCount: Int, + aaMargin: Float, strokeRadius: Float, blurRadius: Float, spreadRadius: Float, ) { + val stroke = strokeRadius > 0f val rowSegmentStart = segmentIndices.size - for (index in 0 until rowSegmentCount) segmentIndices.add(rowIndexScratch[index]) - val crossingCount = if (strokeRadius > 0f) { + if (!stroke) for (index in 0 until rowSegmentCount) segmentIndices.add(rowIndexScratch[index]) + val crossingCount = if (stroke) { 0 } else { buildCrossings(rowSegmentCount, (rowMinY + rowMaxY) * 0.5f).also { @@ -353,15 +370,21 @@ internal class UiPathTileBatch { winding += crossingDeltaScratch[crossingIndex++] } val boundary = boundaryColumnScratch[tileX - minTileX] - if (!boundary && (strokeRadius > 0f || winding == 0)) continue + if (!boundary && (stroke || winding == 0)) continue + val tileSegmentStart = if (stroke) segmentIndices.size else rowSegmentStart + val tileSegmentCount = if (stroke) { + appendStrokeTileSegments(rowSegmentCount, tileMinX, tileMaxX, aaMargin) + } else { + rowSegmentCount + } appendTile( tileMinX, rowMinY, tileMaxX, rowMaxY, transforms[pathIndex], - if (boundary) rowSegmentStart else 0, - if (boundary) rowSegmentCount else 0, + if (boundary) tileSegmentStart else 0, + if (boundary) tileSegmentCount else 0, paintIndex, !boundary, strokeRadius, @@ -371,6 +394,20 @@ internal class UiPathTileBatch { } } + private fun appendStrokeTileSegments(rowSegmentCount: Int, tileMinX: Float, tileMaxX: Float, margin: Float): Int { + var count = 0 + for (rowIndex in 0 until rowSegmentCount) { + val segmentIndex = rowIndexScratch[rowIndex] + val offset = segmentIndex * SegmentStride + val x0 = segments[offset] + val x1 = segments[offset + 2] + if (max(x0, x1) + margin < tileMinX || min(x0, x1) - margin > tileMaxX) continue + segmentIndices.add(segmentIndex) + count++ + } + return count + } + private fun buildCrossings(rowSegmentCount: Int, y: Float): Int { var count = 0 for (rowIndex in 0 until rowSegmentCount) { @@ -485,6 +522,7 @@ internal class UiPathTileBatch { private const val MinimumScale = 0.01f private const val BlurExtentFactor = 3f private const val EffectOffset = 28 + private const val MaxCpuCandidates = 512 private const val MaxComputeCandidates = 262_144 private const val MaxComputeSegmentIndices = 4_194_304L } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileCache.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileCache.kt index 1034ace4e..7d7c89f33 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileCache.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileCache.kt @@ -256,15 +256,16 @@ internal fun UiPathRasterTemplate.appendTo( ) { val tileStart = tiles.size / UiPathTileBatch.TileStride val segmentIndexStart = segmentIndices.size + val point = FloatArray(3) var vertexOffset = 0 while (vertexOffset < this.vertices.size) { val localX = this.vertices[vertexOffset + 3] val localY = this.vertices[vertexOffset + 4] - val point = transform.transform(localX, localY) + transform.transform(localX, localY, 0f, point, 0) vertices.add( - point.x, - point.y, - point.z, + point[0], + point[1], + point[2], localX, localY, this.vertices[vertexOffset + 5] + tileStart, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileRenderer.kt index c6a850dd7..f8d072a46 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileRenderer.kt @@ -14,13 +14,14 @@ internal class UiPathTileRenderer : UiSdfRenderer( name = "path tile", vertexShaderPath = VertexShaderPath, fragmentShaderPath = FragmentShaderPath, - samplerNames = arrayOf("SegmentBuffer", "SegmentIndexBuffer", "TileBuffer", "PaintBuffer", "StopBuffer"), + samplerNames = arrayOf("SegmentBuffer", "SegmentIndexBuffer", "TileBuffer", "PaintBuffer", "StopBuffer", "ClipBuffer"), ) { private val segmentBuffer = UiStreamingTextureBuffer(GL30.GL_RGBA32F) private val segmentIndexBuffer = UiStreamingTextureBuffer(GL30.GL_R32I) private val tileBuffer = UiStreamingTextureBuffer(GL30.GL_RGBA32I) private val paintBuffer = UiStreamingTextureBuffer(GL30.GL_RGBA32F) private val stopBuffer = UiStreamingTextureBuffer(GL30.GL_RGBA32F) + private val clipBuffer = UiStreamingTextureBuffer(GL30.GL_RGBA32F) private val computeDispatcher = UiPathTileComputeDispatcher() private var vertexData: FloatBuffer? = null private var segmentData: FloatBuffer? = null @@ -28,6 +29,7 @@ internal class UiPathTileRenderer : UiSdfRenderer( private var tileData: IntBuffer? = null private var paintData: FloatBuffer? = null private var stopData: FloatBuffer? = null + private var clipData: FloatBuffer? = null override val vertexStrideFloats get() = UiPathTileBatch.VertexStride @@ -70,7 +72,7 @@ internal class UiPathTileRenderer : UiSdfRenderer( bindProgram() uploadMatrices() bindVertexArray() - bindTextureBuffers(arrayOf(segmentBuffer, segmentIndexBuffer, tileBuffer, paintBuffer, stopBuffer)) + bindTextureBuffers(arrayOf(segmentBuffer, segmentIndexBuffer, tileBuffer, paintBuffer, stopBuffer, clipBuffer)) if (computed) { computeDispatcher.drawIndirect() } else { @@ -102,6 +104,7 @@ internal class UiPathTileRenderer : UiSdfRenderer( tileBuffer.close() paintBuffer.close() stopBuffer.close() + clipBuffer.close() computeDispatcher.close() vertexData = vertexData.releaseUiBuffer() segmentData = segmentData.releaseUiBuffer() @@ -109,24 +112,30 @@ internal class UiPathTileRenderer : UiSdfRenderer( tileData = tileData.releaseUiBuffer() paintData = paintData.releaseUiBuffer() stopData = stopData.releaseUiBuffer() + clipData = clipData.releaseUiBuffer() } private fun uploadSharedInputs(batch: UiPathTileBatch) { segmentData = segmentData.ensureUiCapacity(batch.segmentFloatCount) paintData = paintData.ensureUiCapacity(batch.paintFloatCount) stopData = stopData.ensureUiCapacity(batch.stopFloatCount) + clipData = clipData.ensureUiCapacity(batch.clipFloatCount) val segments = segmentData.prepareUiBuffer(batch.segmentFloatCount) val paints = paintData.prepareUiBuffer(batch.paintFloatCount) val stops = stopData.prepareUiBuffer(batch.stopFloatCount) + val clips = clipData.prepareUiBuffer(batch.clipFloatCount) batch.writeSegments(segments) batch.writePaints(paints) batch.writeStops(stops) + batch.writeClips(clips) segments.flip() paints.flip() stops.flip() + clips.flip() segmentBuffer.upload(segments) paintBuffer.upload(paints) stopBuffer.upload(stops) + clipBuffer.upload(clips) } private fun uploadCpuOutput(batch: UiPathTileBatch) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiRendererState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiRendererState.kt index 712ecc60c..fe03b0524 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiRendererState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiRendererState.kt @@ -14,7 +14,6 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiBackfaceVisibility import ru.hollowhorizon.hollowengine.client.ui.style.UiFilterChain import ru.hollowhorizon.hollowengine.client.ui.style.clip import ru.hollowhorizon.hollowengine.client.ui.style.scrollable -import java.util.* import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max @@ -100,14 +99,22 @@ internal fun backdropSampleBounds( internal const val LayerSupersampling = 1f internal const val MaxTransformResolutionScale = 4f +internal const val MinLayerResolutionScale = 0.25f internal const val RotatedResolutionScale = 2f -internal fun transformResolutionScale(transform: UiMatrix4, scratch: FloatArray = FloatArray(2)): Float { +internal fun transformResolutionScale(transform: UiMatrix4, scratch: FloatArray = FloatArray(2)): Float = + resolutionScale(transform, scratch, 1f) + +/** Like [transformResolutionScale], but a layer shrunk on screen also takes fewer pixels in the atlas. */ +internal fun layerResolutionScale(transform: UiMatrix4, scratch: FloatArray = FloatArray(2)): Float = + resolutionScale(transform, scratch, MinLayerResolutionScale) + +private fun resolutionScale(transform: UiMatrix4, scratch: FloatArray, minimum: Float): Float { transform.axisScales(scratch) val magnification = max(scratch[0], scratch[1]) val supersampling = if (transform.isAxisAligned) 1f else RotatedResolutionScale if (!magnification.isFinite()) return 1f - return (magnification * supersampling).coerceIn(1f, MaxTransformResolutionScale) + return (magnification * supersampling).coerceIn(minimum, MaxTransformResolutionScale) } internal fun layerPadding(command: BeginLayerCommand): Float = layerPadding(command.filter) @@ -123,39 +130,28 @@ private const val MaxLayerOverflow = 512f /** * How far each layer node's subtree reaches outside that node's own rect. */ -internal fun UiLayoutResult.layerOverflows(): Map { - val layerNodes = traversalOrder.filter { nodes[it]?.needsFramebuffer == true } - if (layerNodes.isEmpty()) return emptyMap() - - val bounds = IdentityHashMap(nodes.size) - for (index in traversalOrder.indices.reversed()) { - val node = traversalOrder[index] - val layout = nodes[node] ?: continue - var visual = layout.rect - val style = node.resolvedSnapshot - if (!style.clip && !style.scrollable) { - for (child in childrenOf(node)) { - if (child is PopupNode) continue // Popups are drawn by the overlay host, not here. - val childBounds = bounds[child] ?: nodes[child]?.rect ?: continue - visual = visual.union(childBounds) - } - } - bounds[node] = visual - } +/** How far the content of a layer [node] reaches past its rect, which the layer has to leave room for. */ +internal fun UiLayoutResult.layerOverflow(node: UiNode): Float { + val rect = nodes[node]?.rect ?: return 0f + val visual = visualBounds(node) ?: return 0f + return max( + max(rect.x - visual.x, rect.y - visual.y), + max( + visual.x + visual.width - (rect.x + rect.width), + visual.y + visual.height - (rect.y + rect.height), + ), + ).coerceAtLeast(0f) +} - val overflows = IdentityHashMap(layerNodes.size) - for (node in layerNodes) { - val rect = nodes[node]?.rect ?: continue - val visual = bounds[node] ?: continue - overflows[node] = max( - max(rect.x - visual.x, rect.y - visual.y), - max( - visual.x + visual.width - (rect.x + rect.width), - visual.y + visual.height - (rect.y + rect.height), - ), - ).coerceAtLeast(0f) +private fun UiLayoutResult.visualBounds(node: UiNode): UiRect? { + var visual = nodes[node]?.rect ?: return null + val style = node.resolvedSnapshot + if (style.clip || style.scrollable) return visual + for (child in childrenOf(node)) { + if (child is PopupNode) continue // Popups are drawn by the overlay host, not here. + visualBounds(child)?.let { visual = visual.union(it) } } - return overflows + return visual } private fun UiRect.union(other: UiRect): UiRect { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiShapeRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiShapeRenderer.kt index e77e8557e..f59df28ac 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiShapeRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiShapeRenderer.kt @@ -554,7 +554,7 @@ private fun facingAmount(width: Float, height: Float, transform: UiMatrix4): Flo return abs(crossZ / length).coerceIn(0.05f, 1f) } -internal fun UiColor.withOpacity(opacity: Float) = copy(alpha = alpha * opacity) +internal fun UiColor.withOpacity(opacity: Float) = if (opacity == 1f) this else copy(alpha = alpha * opacity) internal fun UiColor.argb(): Int { val a = (alpha * 255f).toInt().coerceIn(0, 255) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextureEffects.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextureEffects.kt index 198c9404b..dc329e378 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextureEffects.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextureEffects.kt @@ -555,7 +555,7 @@ internal object UiTextureEffects { } } -private val UiImageFit.isSliced: Boolean +internal val UiImageFit.isSliced: Boolean get() = this == UiImageFit.NINE_SLICE || this == UiImageFit.THREE_SLICE_VERTICAL || this == UiImageFit.THREE_SLICE_HORIZONTAL diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiEditorDemo.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiEditorDemo.kt index 531bfba75..ab97845bc 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiEditorDemo.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiEditorDemo.kt @@ -4,8 +4,11 @@ import androidx.compose.runtime.* import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeEditorSession +import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinCodeSyntax import ru.hollowhorizon.hollowengine.client.ui.widgets.EditableTextField import ru.hollowhorizon.hollowengine.client.ui.widgets.TextFieldState +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeEditing +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeInsight import ru.hollowhorizon.hollowengine.client.ui.widgets.UiInlayHintsProvider import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextFieldMode @@ -44,16 +47,18 @@ internal fun HollowUiEditorDemo( TextField( value = codeText, mode = UiTextFieldMode.MULTI_LINE, - multiCaret = true, + editing = UiCodeEditing(syntax = KotlinCodeSyntax), onChange = { text -> codeText = text textFieldSession.requestAnalysis(text, text.length) }, - syntaxHighlighter = textFieldSession.highlighter, - completionContributor = textFieldSession.completions, - diagnostics = diagnostics, - inlayHintsProvider = textFieldInlays, - inlayRevision = textFieldRevision, + insight = UiCodeInsight( + highlighter = textFieldSession.highlighter, + completions = textFieldSession.completions, + diagnostics = diagnostics, + inlayHintsProvider = textFieldInlays, + inlayRevision = textFieldRevision, + ), placeholder = "Type code here", tags = listOf("editor-text-field"), modifier = Modifier.size(520.px, 210.px) @@ -99,6 +104,7 @@ internal fun HollowUiEditorDemo( multiline = true, indentSize = 4, autoPairs = true, + syntax = KotlinCodeSyntax, multiCaret = true, ) } @@ -110,9 +116,11 @@ internal fun HollowUiEditorDemo( EditableTextField( state = editableState, tags = listOf("editable-text-field"), - syntaxHighlighter = editableSession.highlighter, - inlayHintsProvider = editableInlays, - inlayRevision = editableRevision, + insight = UiCodeInsight( + highlighter = editableSession.highlighter, + inlayHintsProvider = editableInlays, + inlayRevision = editableRevision, + ), modifier = Modifier.size(500.px, 180.px) .background(UiColor(0.1f, 0.11f, 0.13f, 1f)) .padding(6.px), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptClient.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptClient.kt index 5fad12a97..992a3f7af 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptClient.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptClient.kt @@ -6,7 +6,6 @@ import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.slots.ClientSlots import ru.hollowhorizon.hollowengine.client.utils.mc import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent -import ru.hollowhorizon.hollowengine.common.events.client.ScreenEvent import ru.hollowhorizon.hollowengine.common.events.tick.TickEvent import ru.hollowhorizon.hollowengine.common.ui.UiData import ru.hollowhorizon.hollowengine.common.ui.UiDefinitionRegistry @@ -110,12 +109,3 @@ fun onClientTickResetUi(event: TickEvent.Client) { UiScriptClient.onLevelPresenceChanged(event.minecraft.level != null) UiScriptHudHost.tick() } - -/** - * Draws `aboveScreens` overlays on top of an open screen. The in-HUD render pass does not run while - * a screen is up, so overlays that must stay visible over a menu are drawn from here instead. - */ -@SubscribeEvent -fun onScreenRenderDrawOverlays(event: ScreenEvent.Render.Post) { - UiScriptHudHost.renderAboveScreens(System.nanoTime()) -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptHudHost.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptHudHost.kt index b2223bb67..fd55edb4c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptHudHost.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptHudHost.kt @@ -2,24 +2,31 @@ package ru.hollowhorizon.hollowengine.client.ui.script import com.mojang.blaze3d.systems.RenderSystem import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen import net.minecraft.nbt.CompoundTag import net.minecraft.resources.ResourceLocation import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.ui.script.UiScriptHudHost.dropFinishedExits import ru.hollowhorizon.hollowengine.common.ui.HudPlacement +import ru.hollowhorizon.hollowengine.common.ui.OverlayAnchor +import ru.hollowhorizon.hollowengine.common.ui.ScreenLevel import ru.hollowhorizon.hollowengine.common.ui.UiData import ru.hollowhorizon.hollowengine.common.ui.UiDefinitionRegistry import ru.hollowhorizon.hollowengine.common.ui.UiOverlayDefinition import ru.hollowhorizon.hollowengine.common.ui.hud.HudLayerRegistry +import java.util.EnumSet /** * Draws the scripted HUD layers that are currently shown, and routes input to the ones that opted - * in. Each layer is anchored to a vanilla layer and drawn from that layer's Pre/Post hook, which is - * what lets a scripted overlay sit *between* two vanilla ones without the engine owning a fixed list - * of slots. Overlays flagged `aboveScreens` also draw over an open screen. + * in. A layer in the HUD is drawn from the Pre/Post hook of the vanilla layer it is anchored to, which + * is what lets a scripted overlay sit *between* two vanilla ones without the engine owning a fixed list + * of slots; one on a screen is drawn from the screen's hooks at its [ScreenLevel]. */ object UiScriptHudHost { private val shown = LinkedHashMap() + private val drawnLevels = EnumSet.noneOf(ScreenLevel::class.java) private class ShownOverlay( val definition: UiOverlayDefinition, @@ -113,40 +120,53 @@ object UiScriptHudHost { } } - /** Draws every overlay anchored at [anchor]/[placement] that is not currently suppressed. */ - fun render(anchor: ResourceLocation, placement: HudPlacement, nowNanos: Long) { + /** Draws every overlay that sits in the HUD at [layer]/[placement] right now and is not suppressed. */ + fun render(layer: ResourceLocation, placement: HudPlacement, nowNanos: Long) { dropFinishedExits() if (shown.isEmpty()) return - val screenOpen = Minecraft.getInstance().screen != null + val anchor = OverlayAnchor.HudLayer(layer, placement) + val screen = Minecraft.getInstance().screen?.javaClass shown.values.forEach { overlay -> - if (overlay.definition.anchor != anchor || overlay.definition.placement != placement) return@forEach - if (screenOpen && overlay.definition.aboveScreens) return@forEach - renderOne(overlay, nowNanos) + if (overlay.definition.anchorFor(screen) == anchor) renderOne(overlay, nowNanos) } } - /** Draws the overlays that opted to sit on top of an open screen. */ - fun renderAboveScreens(nowNanos: Long) { + /** Starts a frame of the open screen; each [ScreenLevel] is drawn once in it. */ + fun beginScreenFrame() { + drawnLevels.clear() + } + + /** + * Draws the overlays on top of [screen] up to [level], the levels below it included if the screen skipped + * their hook: a screen without a background still gets its [ScreenLevel.BACKGROUND] overlays. + */ + fun renderOnScreen(screen: Screen, level: ScreenLevel, graphics: GuiGraphics, nowNanos: Long) { dropFinishedExits() - if (shown.isEmpty()) return - shown.values.forEach { overlay -> - if (overlay.definition.aboveScreens) renderOne(overlay, nowNanos) + for (current in ScreenLevel.entries) { + if (current > level) break + if (!drawnLevels.add(current) || shown.isEmpty()) continue + // What the screen batched so far lies under the overlay, so it goes out first. + graphics.flush() + shown.values.forEach { overlay -> + val anchor = overlay.definition.anchorFor(screen.javaClass) + if (anchor is OverlayAnchor.OnScreen && anchor.level == current) renderOne(overlay, nowNanos) + } } } - fun handleMouseMove(x: Float, y: Float): Boolean = dispatch { it.surface.mouseMoved(x, y) } + fun handleMouseMove(x: Float, y: Float): Boolean = dispatch(x, y) { it.surface.mouseMoved(x, y) } fun handleMouseButton(x: Float, y: Float, button: Int, action: Int): Boolean = - dispatch { it.surface.mousePressed(x, y, button, action) } + dispatch(x, y) { it.surface.mousePressed(x, y, button, action) } fun handleMouseScroll(x: Float, y: Float, scrollX: Double, scrollY: Double): Boolean = - dispatch { it.surface.mouseScrolled(x, y, scrollX, scrollY) } + dispatch(x, y) { it.surface.mouseScrolled(x, y, scrollX, scrollY) } fun handleKey(key: Int, scanCode: Int, action: Int, modifiers: Int): Boolean = - dispatch { it.surface.keyPressed(key, scanCode, action, modifiers) } + dispatch(null, null) { it.surface.keyPressed(key, scanCode, action, modifiers) } fun handleChar(codePoint: Int, modifiers: Int): Boolean = - dispatch { it.surface.charTyped(codePoint, modifiers) } + dispatch(null, null) { it.surface.charTyped(codePoint, modifiers) } /** * Drops every shown overlay and brings back the ones scripts declare as auto-showing. Called @@ -214,16 +234,32 @@ object UiScriptHudHost { } /** - * Dispatches an input action to the most recently shown interactive overlay that consumes it. + * Dispatches an input action to the most recently shown interactive overlay that consumes it. [x] and [y] are + * the pointer, null for keys. */ - private inline fun dispatch(action: (ShownOverlay) -> Boolean): Boolean { + private inline fun dispatch(x: Float?, y: Float?, action: (ShownOverlay) -> Boolean): Boolean { if (shown.isEmpty()) return false - val screenOpen = Minecraft.getInstance().screen != null + val screen = Minecraft.getInstance().screen for (overlay in shown.values.reversed()) { - if (!overlay.definition.isInteractive) continue - if (screenOpen && !overlay.definition.aboveScreens) continue + if (!overlay.definition.isInteractive || !takesInput(overlay.definition, screen, x, y)) continue if (action(overlay)) return true } return false } + + private fun takesInput(definition: UiOverlayDefinition, screen: Screen?, x: Float?, y: Float?): Boolean { + val anchor = definition.anchorFor(screen?.javaClass) + if (screen == null) return anchor != null + if (anchor !is OverlayAnchor.OnScreen) return false + if (anchor.level != ScreenLevel.BACKGROUND) return true + return x != null && y != null && !covers(screen, x, y) + } + + /** Whether [screen] has something of its own to click at [x], [y]. */ + private fun covers(screen: Screen, x: Float, y: Float): Boolean { + if (screen.getChildAt(x.toDouble(), y.toDouble()).isPresent) return true + if (screen !is AbstractContainerScreen<*>) return false + return x >= screen.leftPos && x < screen.leftPos + screen.imageWidth && + y >= screen.topPos && y < screen.topPos + screen.imageHeight + } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt index 0b38c1e95..08cafc965 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptLoader.kt @@ -4,7 +4,6 @@ import net.minecraft.server.packs.resources.ResourceManager import net.minecraft.server.packs.resources.ResourceManagerReloadListener import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.api.ReloadListener -import ru.hollowhorizon.hollowengine.client.dialogue.DialogueScreenUi import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader import ru.hollowhorizon.hollowengine.common.scripting.UI_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry @@ -23,7 +22,6 @@ object UiScriptLoader : ResourceManagerReloadListener { fun reload() { UiDefinitionRegistry.clear() - DialogueScreenUi.register(UiDefinitionRegistry::register) ScriptRegistry.list(".$UI_SCRIPT_EXTENSION").forEach { id -> ScriptLoader.execute(id).onFailure { error -> HollowEngine.LOGGER.error("Failed to execute UI script: {}", ScriptRegistry.display(id), error) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPath.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPath.kt index f2f20d8e7..246541fe7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPath.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPath.kt @@ -12,8 +12,15 @@ data class UiPathPoint( data class UiPath( val commands: List, ) { + private var hash = 0 + fun isEmpty(): Boolean = commands.isEmpty() + override fun hashCode(): Int { + if (hash == 0) hash = commands.hashCode() + return hash + } + fun bounds(): UiRect? { var left = Float.POSITIVE_INFINITY var top = Float.POSITIVE_INFINITY diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgShape.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgShape.kt index 535d48d82..47ed452d4 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgShape.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgShape.kt @@ -20,7 +20,10 @@ data class UiSvgPathDocument( val path: UiPath, val viewBox: UiRect, val elements: List = listOf(UiSvgPathElement(path)), -) +) { + /** The shape of each of [elements], in the same order. */ + internal val shapes: List by lazy { elements.map { SvgPathShape(it.path, viewBox) } } +} data class SvgResourceShape( val location: ResourceLocation, @@ -486,7 +489,7 @@ private fun UiSvgStyle.withoutGeometryEffects(): UiSvgStyle { return copy(clipPath = null, mask = null, filter = null) } -private fun parseSvgResourceLocation(location: String): ResourceLocation { +internal fun parseSvgResourceLocation(location: String): ResourceLocation { val trimmed = location.trim() return ResourceLocation.parse(if (trimmed.contains(":")) trimmed else "hollowengine:$trimmed") } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssAst.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssAst.kt index 80941d0b1..a60e8fa2e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssAst.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssAst.kt @@ -96,6 +96,12 @@ data class HssSelector( */ val stateDependent: Boolean = states.isNotEmpty() || ancestor?.stateDependent == true + private val requiredTags = tags.toTypedArray() + + /** The selectors to the left of this one that ask for a state. */ + internal fun statefulAncestors(): List = + generateSequence(ancestor) { it.ancestor }.filter { it.states.isNotEmpty() }.toList() + fun matches(node: UiNode): Boolean { if (!matchesSelf(node)) return false val requiredAncestor = ancestor ?: return true @@ -107,13 +113,16 @@ data class HssSelector( return false } - private fun matchesSelf(node: UiNode): Boolean { + private fun matchesSelf(node: UiNode): Boolean = matchesIgnoringStates(node) && node.hasEffectiveStates(states) + + /** Whether [node] matches this selector alone, whatever state it is in and whatever is above it. */ + internal fun matchesIgnoringStates(node: UiNode): Boolean { if (type != null && node.type != type) return false if (id != null && node.id != id) return false - if (!node.tags.containsAll(tags)) return false - if (!node.hasEffectiveStates(states)) return false - if (attributes.any { !it.matches(node) }) return false - return true + for (tag in requiredTags) { + if (tag !in node.tags) return false + } + return attributes.none { !it.matches(node) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssCompiler.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssCompiler.kt index 30bd54a58..4c13cd8b7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssCompiler.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssCompiler.kt @@ -11,6 +11,11 @@ data class CompiledHss( ) { private val index by lazy { HssRuleIndex(rules) } + /** Everything here that looks at the state of an ancestor, as `.row:hover` does in `.row:hover .icon`. */ + internal val statefulAncestors: List by lazy { + rules.flatMap { it.selector.statefulAncestors() }.distinct() + } + /** Rules of [origin] that match [node], sourced from the index so only candidates are tested. */ fun matching(node: UiNode, origin: StyleOrigin): List = index.matching(node, origin) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiMotion.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiMotion.kt index 3c9db9f01..0f6cf9a23 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiMotion.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiMotion.kt @@ -247,11 +247,13 @@ class UiTransitionState { return target } val oldTarget = state.transitionTarget - if (oldTarget !== target && oldTarget != target) { + val changed = oldTarget !== target && oldTarget != target + state.transitionTarget = target + if (changed) { state.transitionStart = current - state.transitionTarget = target state.transitionStartedAt = nowMillis } else if (state.transitionStartedAt == UiNodeMotionState.NoTimestamp) { + state.rendered = target return target } val startStyle = state.transitionStart ?: current @@ -327,6 +329,13 @@ class UiAnimationState { } } + /** Whether a later resolve can still give [style] something else than this one did. */ + internal fun isRunning(state: UiNodeMotionState, style: UiComputedStyle, nowMillis: Long): Boolean { + if (state.animationSignature == null) return false + val elapsed = nowMillis - state.animationStartedAt + return style.animations.any { animation -> animation.totalDurationMillis()?.let { it > elapsed } ?: true } + } + private fun animationProgress(animation: UiAnimation, elapsedMillis: Long): Float? { val activeElapsed = elapsedMillis - animation.delayMillis if (activeElapsed < 0L) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiResolver.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiResolver.kt index a86c14269..79a2938e9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiResolver.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiResolver.kt @@ -3,7 +3,6 @@ package ru.hollowhorizon.hollowengine.client.ui.style import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollbarThumbType import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollbarType -import java.util.* class UiModifierResolver( private val theme: CompiledHss? = null, @@ -11,12 +10,17 @@ class UiModifierResolver( private val transitions: UiTransitionState = UiTransitionState(), private val animations: UiAnimationState = UiAnimationState(), ) { + /** Whether the last [resolve] found the whole tree as it was left. */ + var lastResolveReused = false + private set + + /** The nodes of the last pass that want to hear from layout: a callback, or a scroll handle to fill in. */ + internal var observedNodes: List = emptyList() + private set + private val resolveStack = StyleResolveStack() - private val stylesheetStack = ArrayDeque() private val stylesheetReferences = ArrayList() private val matchedRulesScratch = ArrayList() - private var stylesheetRoot: UiNode? = null - private var stylesheetTreeDrawRevision = Long.MIN_VALUE private var treeCache: TreeCacheEntry? = null private var nextScopeId = 1L private val rootScope = StyleScope( @@ -26,6 +30,7 @@ class UiModifierResolver( stylesheet?.keyframes?.let(::putAll) }, id = 0L, + statefulAncestors = theme?.statefulAncestors.orEmpty() + stylesheet?.statefulAncestors.orEmpty(), ) fun resolve( @@ -35,7 +40,7 @@ class UiModifierResolver( profile: UiProfileFrame? = null, ): List { if (profile != null) profile.stylePasses++ - val stylesheetRevision = stylesheetRevision(root) + val stylesheetRevision = stylesheetRevision() val treeKey = TreeCacheKey( root = root, subtreeLayoutRevision = root.layoutState.subtreeLayoutRevision, @@ -50,8 +55,10 @@ class UiModifierResolver( profile.styleVisitedNodes += it.nodes.size profile.styleCacheHits += it.nodes.size } + lastResolveReused = true return it.nodes } + lastResolveReused = false val stylesheetChanged = previousTreeCache != null && previousTreeCache.key.stylesheetRevision != stylesheetRevision transitions.beginResolve() @@ -63,7 +70,7 @@ class UiModifierResolver( subtreeLayoutRevision = root.layoutState.subtreeLayoutRevision, subtreeDrawRevision = root.layoutState.subtreeDrawRevision, subtreeInputRevision = root.layoutState.subtreeInputRevision, - stylesheetRevision = stylesheetRevision, + stylesheetRevision = stylesheetRevision(), ), nodes = nodes, requiresRefresh = animate && (transitions.activeDuringResolve || requiresRefresh), @@ -80,6 +87,8 @@ class UiModifierResolver( stylesheetChanged: Boolean, ): Boolean { var requiresRefresh = false + val observed = ArrayList() + stylesheetReferences.clear() val stack = resolveStack stack.clear() stack.push(root, parent = null, scope = rootScope, ancestorRevision = 0L) @@ -92,8 +101,10 @@ class UiModifierResolver( val ancestorRevision = stack.currentAncestorRevision val layoutState = node.layoutState val state = stateFor(node) - val modifiers = flattenedModifiers(state, node, layoutState.drawRevision) - val scopedScope = scopedStyleScope(state, inheritedScope, modifiers) + val flattened = flattenedModifiers(state, node, layoutState.drawRevision) + val modifiers = flattened.modifiers + flattened.imports.forEach { stylesheetReferences += it.reference } + val scopedScope = scopedStyleScope(state, inheritedScope, flattened.imports) val resolved = resolveBaseStyle( state, node, @@ -113,23 +124,26 @@ class UiModifierResolver( transitioned } nodes += node + if (resolved.observed) observed += node node.resolvedModifiers = resolved.modifiers.flat node.resolvedSnapshot = finalStyle // Fingerprint the transitioned + animated style, not the base: an animation on a // layout prop (size/padding/transform) must bump the layout revision so the frame rebuilds // it, while a draw-only animation (color/opacity) leaves the fingerprint and the layout untouched. - node.layoutState.updateResolvedLayoutFingerprint(finalStyle.layoutFingerprint()) - val modifierRefresh = finalStyle.requiresModifierRefresh() + val needsLayer = finalStyle.filter.requiresLayer || finalStyle.opacity < 1f && node.children.isNotEmpty() + node.layoutState.updateResolvedLayoutFingerprint(31 * finalStyle.layoutFingerprint() + needsLayer.hashCode()) + val modifierRefresh = animations.isRunning(state.motion, finalStyle, nowMillis) if (!requiresRefresh && modifierRefresh) requiresRefresh = true if (profile != null && modifierRefresh) profile.animatedNodes++ val descendantRevision = maxOf( ancestorRevision, - layoutState.drawRevision, + selectorRevision(state, node, scopedScope, modifiers, layoutState.drawRevision), ) for (index in node.children.indices.reversed()) { stack.push(node.children[index], finalStyle, scopedScope, descendantRevision) } } + observedNodes = observed return requiresRefresh } @@ -139,38 +153,53 @@ class UiModifierResolver( return NodeResolveState(this).also { node.layoutState.styleResolverState = it } } + /** + * What the selectors of the nodes below [node] can see of it. The states count only as far as some selector asks an + * ancestor like this one for them: otherwise the pointer entering a panel would restyle all that is inside it. + */ + private fun selectorRevision( + state: NodeResolveState, + node: UiNode, + scope: StyleScope, + modifiers: List, + drawRevision: Long, + ): Long { + val cached = state.selectorEntry + if (cached?.drawRevision == drawRevision) return cached.revision + val signature = SelectorSignature( + node.tags.toSet(), + node.attributes.toMap(), + modifiers.filterIsInstance(), + visibleStates(node, scope), + ) + val revision = cached?.takeIf { it.signature == signature }?.revision ?: drawRevision + state.selectorEntry = SelectorEntry(signature, drawRevision, revision) + return revision + } + + private fun visibleStates(node: UiNode, scope: StyleScope): Set { + var asked: HashSet? = null + for (selector in scope.statefulAncestors) { + if (!selector.matchesIgnoringStates(node)) continue + (asked ?: HashSet().also { asked = it }) += selector.states + } + val states = asked ?: return emptySet() + return node.effectiveStates().filterTo(HashSet()) { it in states } + } + private fun flattenedModifiers( state: NodeResolveState, node: UiNode, drawRevision: Long, - ): List { + ): ModifierCacheEntry { // Modifier/tags/state/attribute collections invalidate drawRevision. layoutRevision also // changes for every frame of a layout animation, so using it here would defeat this cache. - state.modifierEntry?.takeIf { it.drawRevision == drawRevision }?.let { return it.modifiers } - return node.modifiers.flattenModifiers().also { modifiers -> - state.modifierEntry = ModifierCacheEntry(drawRevision, modifiers) - } + state.modifierEntry?.takeIf { it.drawRevision == drawRevision }?.let { return it } + return ModifierCacheEntry(drawRevision, node.modifiers.flattenModifiers()).also { state.modifierEntry = it } } - private fun stylesheetRevision(root: UiNode): Long { - val drawRevision = root.layoutState.subtreeDrawRevision - // Imports only need rediscovery after a source-style change; resource revisions themselves - // are still sampled every frame so a resource reload invalidates the tree immediately. - if (stylesheetRoot !== root || stylesheetTreeDrawRevision != drawRevision) { - stylesheetRoot = root - stylesheetTreeDrawRevision = drawRevision - stylesheetReferences.clear() - stylesheetStack.clear() - stylesheetStack.add(root) - while (stylesheetStack.isNotEmpty()) { - val node = stylesheetStack.removeLast() - for (modifier in flattenedModifiers(stateFor(node), node, node.layoutState.drawRevision)) { - if (modifier is StyleImportModifier) stylesheetReferences += modifier.reference - } - for (child in node.children) stylesheetStack.add(child) - } - } - + /** Resource revisions are sampled every frame, so that a reload of a sheet invalidates the tree immediately. */ + private fun stylesheetRevision(): Long { var revision = 1L for (reference in stylesheetReferences) revision = revision * 31L + reference.revision() return revision @@ -274,27 +303,23 @@ class UiModifierResolver( private fun scopedStyleScope( state: NodeResolveState, inheritedScope: StyleScope, - modifiers: List, + imports: List, ): StyleScope { - var imports: ArrayList? = null - for (modifier in modifiers) { - if (modifier !is StyleImportModifier) continue - if (imports == null) imports = ArrayList() - imports += modifier - } - val resolvedImports = imports ?: return inheritedScope - state.scopeEntry?.takeIf { it.matches(inheritedScope.id, resolvedImports) }?.let { return it.scope } - val snapshots = resolvedImports.map { StyleImportSnapshot(it.reference, it.reference.revision()) } + if (imports.isEmpty()) return inheritedScope + state.scopeEntry?.takeIf { it.matches(inheritedScope.id, imports) }?.let { return it.scope } + val snapshots = imports.map { StyleImportSnapshot(it.reference, it.reference.revision()) } val key = ScopeCacheKey(inheritedScope.id, snapshots) - val stylesheets = ArrayList(inheritedScope.stylesheets.size + resolvedImports.size) + val stylesheets = ArrayList(inheritedScope.stylesheets.size + imports.size) stylesheets += inheritedScope.stylesheets val keyframes = LinkedHashMap(inheritedScope.keyframes) - resolvedImports.forEach { modifier -> + val statefulAncestors = LinkedHashSet(inheritedScope.statefulAncestors) + imports.forEach { modifier -> val stylesheet = modifier.reference.resolve() stylesheets += stylesheet keyframes.putAll(stylesheet.keyframes) + statefulAncestors += stylesheet.statefulAncestors } - return StyleScope(stylesheets, keyframes, nextScopeId++).also { scope -> + return StyleScope(stylesheets, keyframes, nextScopeId++, statefulAncestors.toList()).also { scope -> state.scopeEntry = ScopeCacheEntry(key, scope) } } @@ -410,9 +435,23 @@ private class NodeResolveState(val owner: UiModifierResolver) { var modifierEntry: ModifierCacheEntry? = null var scopeEntry: ScopeCacheEntry? = null var styleEntry: StyleCacheEntry? = null + var selectorEntry: SelectorEntry? = null val motion = UiNodeMotionState() } +private data class SelectorSignature( + val tags: Set, + val attributes: Map, + val attributeModifiers: List, + val states: Set, +) + +private class SelectorEntry( + val signature: SelectorSignature, + val drawRevision: Long, + val revision: Long, +) + private class ResolvedModifiers( val flat: List, val baseModifiers: List, @@ -422,12 +461,15 @@ private class ResolvedModifiers( private class ResolvedNodeStyle( val modifiers: ResolvedModifiers, val snapshot: UiComputedStyle, -) +) { + val observed = snapshot.scroll != null || modifiers.flat.any { it.reportsLayout() } +} private data class StyleScope( val stylesheets: List, val keyframes: Map, val id: Long, + val statefulAncestors: List, ) private data class StyleImportSnapshot( @@ -488,7 +530,9 @@ private data class StyleCacheEntry( private class ModifierCacheEntry( val drawRevision: Long, val modifiers: List, -) +) { + val imports: List = modifiers.filterIsInstance() +} private class StyleResolveStack(initialCapacity: Int = 64) { private var nodes = arrayOfNulls(initialCapacity) @@ -562,8 +606,3 @@ private data class TreeCacheEntry( val requiresRefresh: Boolean, ) -private fun UiComputedStyle.requiresModifierRefresh(): Boolean { - val animations = animations - if (animations.isEmpty()) return false - return animations.any { animation -> animation.totalDurationMillis()?.let { it > 0L } ?: true } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleProps.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleProps.kt index 786bdcdde..2170c393c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleProps.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleProps.kt @@ -149,7 +149,7 @@ object UiProps { val BackfaceVisibility = prop("backface-visibility", UiBackfaceVisibility.VISIBLE) val Clip = prop("clip", false, fingerprint = true) val ClipShape = prop("clip-shape", null, fingerprint = true) - val Layer = prop("layer", 0) + val Layer = prop("layer", 0, fingerprint = true) val ImageFit = prop("image-fit", UiImageFit.STRETCH) val ImageSlice = prop("image-slice", UiInsets.all(4.px)) val ImageUv = prop("image-uv", UiImageUv.Full, aliases = setOf("uv")) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleValues.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleValues.kt index 5fe56b558..341d19047 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleValues.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStyleValues.kt @@ -51,7 +51,9 @@ data class UiUvRect(val u0: Float, val v0: Float, val u1: Float, val v1: Float) sealed interface UiPaint { data object None : UiPaint - data class Color(val color: UiColor) : UiPaint + data class Color(val color: UiColor) : UiPaint { + internal val resolved = UiResolvedPaint.Color(color) + } data class LinearGradient(val angleDegrees: Float, val stops: List) : UiPaint data class RadialGradient(val gradient: UiRadialGradient) : UiPaint data class Image(val source: String) : UiPaint diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiDynamicGlyphFont.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiDynamicGlyphFont.kt index e66558ed5..d89ea84ae 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiDynamicGlyphFont.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiDynamicGlyphFont.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.client.ui.text import com.mojang.blaze3d.platform.NativeImage import com.mojang.blaze3d.platform.TextureUtil import com.mojang.blaze3d.systems.RenderSystem +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap import org.lwjgl.opengl.GL11 import ru.hollowhorizon.hollowengine.client.utils.font.DynamicGlyphAtlas import ru.hollowhorizon.hollowengine.client.utils.font.GlyphCellUploader @@ -74,7 +75,7 @@ internal class UiDynamicGlyphFont( sampling = UiGlyphSampling.MSDF, ) - private val placed = HashMap() + private val placed = Int2ObjectOpenHashMap() private var placedEpoch = -1 override val lineHeight: Float get() = metrics.meta.metrics.lineHeight diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextLayout.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextLayout.kt index 5c36ff9e1..db5a5d83c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextLayout.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextLayout.kt @@ -12,7 +12,9 @@ data class UiTextLayout( val width: Float, val height: Float, val maxNaturalLineWidth: Float = lines.maxOfOrNull { it.naturalWidth } ?: width, -) +) { + fun hasInlineImages(): Boolean = lines.any { line -> line.fragments.any { it is UiInlineImageRun } } +} data class UiTextLine( val text: String, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt deleted file mode 100644 index 4cdfceecf..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/OrbitPivot.kt +++ /dev/null @@ -1,38 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.widgets - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import ru.hollowhorizon.hollowengine.common.config.HollowEngineConfig -import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f - -/** - * What every preview of the editor turns around: its own center, or what is selected in it. One setting for all of - * them, kept in the config. - */ -object OrbitPivot { - private var loaded = false - private var selectionValue by mutableStateOf(false) - - var aroundSelection: Boolean - get() { - load() - return selectionValue - } - set(value) { - load() - if (selectionValue == value) return - selectionValue = value - HollowEngineConfig.previewOrbitSelection = value - } - - /** Where [camera] turns about now: the selection when the previews circle it and there is one, else null. */ - fun of(camera: OrbitCamera): Vec3f? = - if (aroundSelection) camera.selectionCenter else null - - private fun load() { - if (loaded) return - loaded = true - selectionValue = HollowEngineConfig.previewOrbitSelection - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldAutoScroll.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldAutoScroll.kt index bc7a77728..c60573598 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldAutoScroll.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldAutoScroll.kt @@ -6,6 +6,7 @@ import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle /** Follows caret requests across editor mounts without losing the user's scroll position. */ internal class TextFieldAutoScroll { private var caretRevision = 0 + private var dropOffset: Int? = null private var caretMovePending = false private var followedRevision = Int.MIN_VALUE private var settling = false @@ -15,14 +16,19 @@ internal class TextFieldAutoScroll { fun follow(state: TextFieldState, layout: EditableFieldLayout, scrollState: UiScrollHandle, gutterWidth: Float) { val revision = state.caretVisibilityRevision + val drop = state.dropPreview.offset + if (drop != dropOffset) { + if (drop != null) caretMovePending = true else settling = false + dropOffset = drop + } if (caretRevision != revision) caretMovePending = true caretRevision = revision val viewport = scrollState.viewport if (!caretMovePending) { if (!settling || followedRevision != revision) return - val unchanged = followedViewport == viewport && - followedContentWidth == layout.naturalWidth && followedContentHeight == layout.height + val unchanged = + followedViewport == viewport && followedContentWidth == layout.naturalWidth && followedContentHeight == layout.height if (unchanged) { settling = false return @@ -36,15 +42,15 @@ internal class TextFieldAutoScroll { followedContentWidth = layout.naturalWidth followedContentHeight = layout.height - val caret = layout.caretAt(state.primaryCaret.position, state.primaryCaret.inlayAffinity) + val caret = if (drop != null) layout.caretAt(drop) + else layout.caretAt(state.primaryCaret.position, state.primaryCaret.inlayAffinity) val margin = layout.fontSize val caretEndX = caret.x + TextFieldCaretWidth val viewportWidth = (viewport.width - gutterWidth).coerceAtLeast(0f) val targetY = when { caret.y < scrollState.offsetY + margin -> (caret.y - margin).coerceAtLeast(0f) - caret.y + layout.fontSize > scrollState.offsetY + viewport.height - margin -> - caret.y + layout.fontSize + margin - viewport.height + caret.y + layout.fontSize > scrollState.offsetY + viewport.height - margin -> caret.y + layout.fontSize + margin - viewport.height else -> null } val targetX = when { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeEditing.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeEditing.kt new file mode 100644 index 000000000..6600dd8e1 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeEditing.kt @@ -0,0 +1,150 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + + + +internal data class TextFieldCodeEdit( + val start: Int, + val end: Int, + val replacement: String, + val caretOffset: Int = replacement.length, + val anchorOffset: Int? = null, +) + +internal fun TextFieldState.applyCodeEdits(edits: List): Boolean { + if (edits.isEmpty() || readOnly) return false + val result = codeEditResult(text, edits) ?: return false + if (!filter.accepts(result.text)) return false + val moved = result.carets != caretRanges + return applyEdit(result.text, result.carets) || moved +} + +private data class CodeEditResult(val text: String, val carets: List) + +private fun codeEditResult(text: String, edits: List): CodeEditResult? { + val ordered = edits.distinct().sortedBy { it.start } + if (ordered.zipWithNext().any { (left, right) -> right.start < left.end }) return null + val carets = ArrayList() + val next = buildString { + var cursor = 0 + for (edit in ordered) { + append(text, cursor, edit.start) + carets += UiTextCaret(length + edit.caretOffset, edit.anchorOffset?.let { length + it }) + append(edit.replacement) + cursor = edit.end + } + append(text, cursor, text.length) + } + return CodeEditResult(next, carets) +} + +internal fun TextFieldState.typeCodeCharacter(char: Char): Boolean { + if (readOnly) return false + val ranges = if (multiCaret) caretRanges else listOf(primaryCaret) + val size = indentSize + val code = syntax + if (char == '.' && code != null && multiline && size != null && size > 0) { + val continuations = ranges.map { if (it.hasSelection) null else continuationDotEdit(text, it.position, size, code) } + if (continuations.any { it != null }) { + return applyCodeEdits(ranges.zip(continuations) { range, edit -> + edit ?: TextFieldCodeEdit(range.selectionStart, range.selectionEnd, ".") + }) + } + } + if (!autoPairs || char !in EditorPairClosings && char !in EditorPairClosings.values) return insert(char.toString()) + val edits = ranges.map { range -> + val closing = EditorPairClosings[char] + when { + range.hasSelection && closing != null -> surroundEdit(range, char, closing) + range.hasSelection -> TextFieldCodeEdit(range.selectionStart, range.selectionEnd, char.toString()) + else -> characterEdit(range.position, char) + } + } + val typed = codeEditResult(text, edits) ?: return false + val result = if (code != null && multiline && size != null && char in "})]") typed.reindentClosings(char, size, code) else typed + if (!filter.accepts(result.text)) return false + val moved = result.carets != caretRanges + return applyEdit(result.text, result.carets) || moved +} + +private fun CodeEditResult.reindentClosings(char: Char, size: Int, syntax: UiCodeSyntax): CodeEditResult { + val structure = TextFieldCodeStructure(text, text.length, syntax) + val changes = carets.sortedByDescending { it.position }.flatMap { range -> + val position = range.position - 1 + if (position >= 0 && syntax.isCodeAt(text, position)) structure.closingIndents(position, char, size) + else emptyList() + }.distinctBy { it.start }.sortedBy { it.start } + if (changes.isEmpty()) return this + val next = buildString { + var cursor = 0 + changes.forEach { change -> + append(text, cursor, change.start) + append(change.replacement) + cursor = change.end + } + append(text, cursor, text.length) + } + fun mapped(position: Int): Int { + var shift = 0 + for (change in changes) { + if (position < change.start) break + if (position < change.end) return change.start + shift + (position - change.start).coerceAtMost(change.replacement.length) + shift += change.replacement.length - (change.end - change.start) + } + return position + shift + } + return CodeEditResult(next, carets.map { it.copy(position = mapped(it.position), selectionAnchor = it.selectionAnchor?.let(::mapped)) }) +} + +private fun TextFieldState.characterEdit(position: Int, char: Char): TextFieldCodeEdit { + if (char in EditorPairClosings.values && text.getOrNull(position) == char) { + return TextFieldCodeEdit(position, position + 1, char.toString()) + } + val closing = EditorPairClosings[char] + return if (closing != null && pairs(position, char)) { + TextFieldCodeEdit(position, position, "$char$closing", caretOffset = 1) + } else TextFieldCodeEdit(position, position, char.toString()) +} + +/** + * Whether typing [char] at [position] adds its closing pair: with a [syntax], only in code, not in its strings and + * comments; in plain text, unless a quote follows a letter, as the apostrophe in "don't" does. + */ +private fun TextFieldState.pairs(position: Int, char: Char): Boolean { + syntax?.let { return it.isCodeAt(text, position) } + return char != '"' && char != '\'' || text.getOrNull(position - 1)?.isLetterOrDigit() != true +} + +private fun TextFieldState.surroundEdit(range: UiTextCaret, opening: Char, closing: Char): TextFieldCodeEdit { + val selected = text.substring(range.selectionStart, range.selectionEnd) + val size = indentSize + val code = syntax + val replacement = if (code != null && multiline && size != null && opening in "{([" && '\n' in selected && code.isCodeAt(text, range.selectionStart)) { + val base = text.indentOfLine(range.selectionStart) + val leading = text.substring(lineStart(text, range.selectionStart), range.selectionStart) + .takeIf { prefix -> prefix.all { it == ' ' || it == '\t' } }.orEmpty() + val lines = (leading + selected).split('\n') + val structure = TextFieldCodeStructure(text, text.length, code) + var offset = range.selectionStart - leading.length + val content = lines.map { line -> + val preserve = structure.preservesLineIndent(offset) + offset += line.length + 1 + line to preserve + } + val margin = content.filter { (line, preserve) -> !preserve && line.isNotBlank() } + .minOfOrNull { (line, _) -> line.takeWhile { it == ' ' || it == '\t' }.length } ?: 0 + val indent = base + " ".repeat(size.coerceAtLeast(0)) + val body = content.joinToString("\n") { (line, preserve) -> + when { + preserve -> line + line.isBlank() -> "" + else -> indent + line.drop(margin) + } + } + "$opening\n$body\n$base$closing" + } else "$opening$selected$closing" + val backwards = range.position < (range.selectionAnchor ?: range.position) + return TextFieldCodeEdit(range.selectionStart, range.selectionEnd, replacement, + caretOffset = if (backwards) 0 else replacement.length, + anchorOffset = if (backwards) replacement.length else 0) +} + diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeStructure.kt new file mode 100644 index 000000000..3d31154fd --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeStructure.kt @@ -0,0 +1,91 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +/** Reads [text] by the language's [syntax], so delimiters inside literals and comments never affect edits. */ +internal class TextFieldCodeStructure(private val text: String, limit: Int, syntax: UiCodeSyntax) { + private val code = BooleanArray(limit) + private val stringLines = HashSet() + + init { + syntax.scan(text, limit, { code[it] = true }, { offset, inString -> if (inString) stringLines += offset }) + } + + fun openingsBefore(position: Int): List { + val stack = ArrayList() + for (index in 0 until position) { + if (!code[index]) continue + val char = text[index] + when { + char in "{([" -> stack += index + stack.isNotEmpty() && EditorPairClosings[text[stack.last()]] == char -> stack.removeLast() + } + } + return stack + } + + fun blockIndent(position: Int, size: Int): String? = openingsBefore(position).lastOrNull()?.let { + text.indentOfLine(it) + " ".repeat(size.coerceAtLeast(0)) + } + + fun preservesLineIndent(position: Int): Boolean = position in stringLines + + /** Indents the block that the [char] at [position] closes. */ + fun closingIndents(position: Int, char: Char, size: Int): List { + if (char !in "})]") return emptyList() + val start = lineStart(text, position) + if (text.substring(start, position).any { it != ' ' && it != '\t' }) return emptyList() + val opening = openingsBefore(position).lastOrNull() ?: return emptyList() + if (EditorPairClosings[text[opening]] != char) return emptyList() + val step = " ".repeat(size.coerceAtLeast(0)) + val base = text.indentOfLine(opening) + val open = arrayListOf(base) + var offset = text.indexOf('\n', opening).takeIf { it in (opening + 1) until position }?.plus(1) ?: start + var previousEnd: Int? = null + val edits = ArrayList() + while (offset <= start) { + val end = lineEnd(text, offset) + val first = (offset until end).firstOrNull { text[it] != ' ' && text[it] != '\t' } + var indent = first?.let { text.substring(offset, it) } + if (first != null && !preservesLineIndent(offset)) { + val closes = code[first] && text[first] in "})]" + indent = when { + offset == start -> base + closes -> open.last() + continues(first, previousEnd) -> open.last() + step + step + else -> open.last() + step + } + edits += TextFieldIndentEdit(offset, first, indent) + } + val depth = open.size + for (index in offset until end) { + if (!code[index]) continue + if (text[index] in "{([") open += indent.orEmpty() + else if (text[index] in "})]" && open.size > 1) open.removeAt(open.lastIndex) + } + if (first != null) previousEnd = lastCodeIndex(offset, end).takeIf { open.size <= depth } + offset = end + 1 + } + return edits + } + + /** Whether the line starting with code at [first] goes on with the expression that ended at [previousEnd]. */ + private fun continues(first: Int, previousEnd: Int?): Boolean { + if (code[first] && ContinuationStarts.any { text.startsWith(it, first) }) return true + val end = previousEnd ?: return false + return ContinuationEnds.any { end - it.length + 1 >= 0 && text.startsWith(it, end - it.length + 1) } + } + + private fun lastCodeIndex(from: Int, to: Int): Int? = + (to - 1 downTo from).firstOrNull { code[it] && !text[it].isWhitespace() } + + private companion object { + val ContinuationStarts = listOf(".", "?.", "?:", "&&", "||") + val ContinuationEnds = listOf("=", "+", "-", "*", "/", "%", "&&", "||", "?:", "->") + } +} + +internal data class TextFieldIndentEdit(val start: Int, val end: Int, val replacement: String) + +internal fun String.indentOfLine(position: Int): String = + substring(lineStart(this, position), lineEnd(this, position)).takeWhile { it == ' ' || it == '\t' } + +internal val EditorPairClosings = mapOf('(' to ')', '[' to ']', '{' to '}', '<' to '>', '"' to '"', '\'' to '\'') diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt index a61d8e85e..fd080568f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCompletionState.kt @@ -175,6 +175,7 @@ class TextFieldCompletionState( val next = imported.substring(0, start) + insertText + imported.substring(end) val caret = start + (item.caretOffset ?: insertText.length).coerceIn(0, insertText.length) val changed = state.applyEdit(next, listOf(UiTextCaret(caret))) + item.onAccept?.invoke() close() return changed } @@ -230,12 +231,9 @@ private fun rankAgainst(candidates: List, prefix: String): Lis candidates.asSequence() .filter { it.label.isNotBlank() || it.insertText.isNotBlank() } .mapIndexedNotNull { index, item -> - val filterMatch = matchCompletion(prefix, item.filterText) - val labelMatch = if (item.filterText == item.label) filterMatch else matchCompletion(prefix, item.label) - val match = filterMatch ?: labelMatch ?: return@mapIndexedNotNull null - val displayRanges = labelMatch?.ranges ?: filterMatch?.ranges - ?.shiftInto(item.filterText, item.label) - .orEmpty() + val match = matchCompletion(prefix, item.filterText) ?: return@mapIndexedNotNull null + val displayRanges = if (item.filterText == item.label) match.ranges + else match.ranges.shiftInto(item.filterText, item.label) RankedCompletion(item.copy(matchRanges = displayRanges), match.score, index) } .sortedWith( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldDropPreview.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldDropPreview.kt new file mode 100644 index 000000000..dda7212df --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldDropPreview.kt @@ -0,0 +1,31 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** A drop indicator never changes the real selection, focus or undo history. */ +internal class TextFieldDropPreview { + var offset: Int? by mutableStateOf(null) + private set + private var point: Pair? = null + + fun show(offset: Int) { + this.offset = offset + } + + fun track(x: Float, y: Float, offsetAtPoint: ((Float, Float) -> Int)?) { + point = x to y + refresh(offsetAtPoint) + } + + fun refresh(offsetAtPoint: ((Float, Float) -> Int)?) { + val (x, y) = point ?: return + offsetAtPoint?.invoke(x, y)?.let(::show) + } + + fun clear() { + point = null + offset = null + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldIndentation.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldIndentation.kt new file mode 100644 index 000000000..14eec0831 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldIndentation.kt @@ -0,0 +1,46 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +internal fun TextFieldState.indentLines(): Boolean { + val size = indentSize?.coerceAtLeast(0) ?: return false + if (readOnly || !multiline || size == 0) return false + if (caretRanges.all { !it.hasSelection }) return insert(" ".repeat(size)) + return applyCodeEdits(selectedLineStarts().map { TextFieldCodeEdit(it, it, " ".repeat(size)) }) +} + +internal fun TextFieldState.unindentLines(): Boolean { + val size = indentSize?.coerceAtLeast(0) ?: return false + if (readOnly || !multiline || size == 0) return false + val edits = selectedLineStarts().mapNotNull { start -> + val end = if (text.getOrNull(start) == '\t') start + 1 + else start + text.substring(start, (start + size).coerceAtMost(text.length)).takeWhile { it == ' ' }.length + if (end > start) TextFieldCodeEdit(start, end, "") else null + } + return edits.isNotEmpty() && applyCodeEdits(edits) +} + +internal fun TextFieldState.newlineWithIndent(): Boolean { + if (readOnly || !multiline) return false + val size = indentSize ?: return insert("\n") + if (caretRanges.any { it.hasSelection }) return insert("\n") + return applyCodeEdits(caretRanges.map { + val edit = newlineEdit(text, it.position, size, syntax) + TextFieldCodeEdit(edit.start, edit.end, edit.replacement, edit.caretOffset) + }) +} + +private fun TextFieldState.selectedLineStarts(): List { + val starts = linkedSetOf() + for (range in caretRanges) { + val from = range.selectionStart + val rawTo = range.selectionEnd + val to = if (rawTo > from && text.getOrNull(rawTo - 1) == '\n') rawTo - 1 else rawTo + var start = lineStart(text, from) + while (start <= to) { + starts += start + val next = text.indexOf('\n', start) + if (next < 0 || next >= to) break + start = next + 1 + } + } + return starts.toList() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldNewline.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldNewline.kt index c07cc745b..fbab81eef 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldNewline.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldNewline.kt @@ -1,37 +1,37 @@ package ru.hollowhorizon.hollowengine.client.ui.widgets -import ru.hollowhorizon.hollowengine.common.scripting.ide.isKotlinCodeAt - internal data class NewlineEdit(val start: Int, val end: Int, val replacement: String, val caretOffset: Int) -internal fun newlineEdit(text: String, caret: Int, indentSize: Int): NewlineEdit { +/** + * Breaks the line at [caret], one step deeper after an opening delimiter. With a [syntax], a blank line and a call + * chain take the indent of the block they are in, and literals and comments keep the indent of their line. + */ +internal fun newlineEdit(text: String, caret: Int, indentSize: Int, syntax: UiCodeSyntax?): NewlineEdit { val position = caret.coerceIn(0, text.length) val lineStart = if (position == 0) 0 else text.lastIndexOf('\n', position - 1) + 1 val lineEnd = text.indexOf('\n', position).let { if (it < 0) text.length else it } val line = text.substring(lineStart, lineEnd) - if (!text.isKotlinCodeAt(position)) { + if (syntax != null && !syntax.isCodeAt(text, position)) { val indent = line.takeWhile { it == ' ' || it == '\t' } return NewlineEdit(position, position, "\n$indent", 1 + indent.length) } val before = text.substring(lineStart, position).trimEnd(' ', '\t') val after = text.substring(position, lineEnd).trimStart(' ', '\t') - var baseIndent = line.takeWhile { it == ' ' || it == '\t' } - var previousOpens = false - if (line.isBlank()) { - val previous = text.substring(0, lineStart).lineSequence().lastOrNull { it.isNotBlank() } - if (previous != null) { - baseIndent = previous.takeWhile { it == ' ' || it == '\t' } - previousOpens = previous.trimEnd().lastOrNull()?.let { it in "{([" } == true - } else if (indentSize > 0) { - baseIndent = " ".repeat((position - lineStart) / indentSize * indentSize) - } - } + val structuralIndent = syntax?.let { TextFieldCodeStructure(text, position, it).blockIndent(position, indentSize) } + val currentIndent = line.takeWhile { it == ' ' || it == '\t' } val opening = before.lastOrNull() + val opens = opening?.let { it in "{([" } == true + val baseIndent = when { + line.isBlank() && syntax != null -> structuralIndent.orEmpty() + line.isBlank() -> text.substring(0, lineStart).lineSequence().lastOrNull { it.isNotBlank() } + ?.takeWhile { it == ' ' || it == '\t' }.orEmpty() + syntax != null && !opens && before.trimStart().startsWith('.') -> structuralIndent.orEmpty() + else -> currentIndent + } val closing = after.firstOrNull() - val opens = opening?.let { it in "{([" } == true || line.isBlank() && previousOpens val closes = closing?.let { it in "})]" } == true val pair = when (opening) { '{' -> '}'; '(' -> ')'; '[' -> ']'; else -> null } - val splitPair = opens && closes && (pair == closing || line.isBlank()) + val splitPair = opens && closes && pair == closing val indent = if (opens) baseIndent + " ".repeat(indentSize.coerceAtLeast(0)) else baseIndent // Whitespace around block delimiters belongs to the old inline layout. val start = if (opens && before.isNotEmpty()) lineStart + before.length else position diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldPointerInput.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldPointerInput.kt new file mode 100644 index 000000000..76c5d0176 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldPointerInput.kt @@ -0,0 +1,207 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import org.lwjgl.glfw.GLFW +import kotlin.math.abs + +internal fun handleEditableFieldPress( + state: TextFieldState, + offset: Int, + clickCount: Int, + modifiers: Int, + pointerState: EditableFieldPointerState = EditableFieldPointerState().also { + it.beginPress(state.text, offset, clickCount, modifiers and GLFW.GLFW_MOD_ALT != 0) + }, + inlayAffinity: UiInlayCaretAffinity = UiInlayCaretAffinity.AFTER, + allowSelectionMove: Boolean = true, +) { + val altPressed = modifiers and GLFW.GLFW_MOD_ALT != 0 + if (allowSelectionMove && clickCount == 1 && !altPressed && modifiers and GLFW.GLFW_MOD_SHIFT == 0 && pointerState.beginSelectionMove( + state, + offset + ) + ) return + when { + clickCount >= 3 -> toggleOrAddPointerSelection(state, pointerState, offset, altPressed) + clickCount == 2 -> toggleOrAddPointerSelection(state, pointerState, offset, altPressed) + altPressed && state.removeCaretRangeAt(offset) -> pointerState.cancelDrag() + altPressed -> state.addCaret(offset, inlayAffinity) + else -> state.moveCaret(offset, inlayAffinity = inlayAffinity) + } +} + +internal fun handleEditableFieldDrag(state: TextFieldState, offset: Int, pointerState: EditableFieldPointerState) { + if (pointerState.movingSelection) { + pointerState.moveDragged = true + state.dropPreview.show(offset.coerceIn(0, state.text.length)) + return + } + val selection = pointerState.dragSelection(state.text, offset) ?: return + applyPointerSelection(state, selection, pointerState.altPressed) +} + +internal fun handleEditableFieldRelease( + state: TextFieldState, offset: Int, modifiers: Int, pointerState: EditableFieldPointerState, inside: Boolean = true, +): Boolean { + if (!pointerState.movingSelection) { + pointerState.cancelDrag() + return false + } + if (!pointerState.moveDragged) state.moveCaret(offset) + else if (inside && pointerState.matchesSelection(state)) { + val copy = modifiers and (GLFW.GLFW_MOD_CONTROL or GLFW.GLFW_MOD_SUPER) != 0 + state.moveSelections(offset, copy) + } + state.clearDropPreview() + pointerState.cancelDrag() + return true +} + +private fun toggleOrAddPointerSelection( + state: TextFieldState, + pointerState: EditableFieldPointerState, + offset: Int, + altPressed: Boolean, +) { + val selection = pointerState.dragSelection(state.text, offset) ?: return + if (altPressed && state.removeCaretRange(selection)) { + pointerState.cancelDrag() + return + } + addPointerSelection(state, selection, altPressed) +} + +private fun addPointerSelection(state: TextFieldState, selection: UiTextCaret?, altPressed: Boolean) { + selection ?: return + val anchor = selection.selectionAnchor ?: selection.position + if (altPressed) state.addCaretRange(UiTextCaret(selection.position, anchor)) + else state.setSelection(anchor, selection.position) +} + +private fun applyPointerSelection(state: TextFieldState, selection: UiTextCaret?, altPressed: Boolean) { + selection ?: return + val anchor = selection.selectionAnchor ?: selection.position + if (altPressed) state.updateLastCaretRange(anchor, selection.position) + else state.setSelection(anchor, selection.position) +} + +internal class EditableFieldPointerState { + var movingSelection = false + private set + var moveDragged = false + private var sourceText = "" + private var sourceCarets = emptyList() + var altPressed = false + private set + + private var anchor = 0 + private var mode = EditableFieldSelectionMode.CHARACTER + private var anchorRange = EditableTextRange(0, 0) + private var dragEnabled = false + private var lastClickAtMillis = 0L + private var lastClickOffset = -1 + private var lastClickCount = 0 + + fun beginPress(text: String, offset: Int, clickCount: Int, altPressed: Boolean) { + movingSelection = false + moveDragged = false + sourceText = "" + sourceCarets = emptyList() + this.altPressed = altPressed + anchor = offset.coerceIn(0, text.length) + mode = when { + clickCount >= 3 -> EditableFieldSelectionMode.LINE + clickCount == 2 -> EditableFieldSelectionMode.WORD + else -> EditableFieldSelectionMode.CHARACTER + } + anchorRange = selectionUnitAt(text, anchor, mode) + dragEnabled = true + } + + fun cancelDrag() { + dragEnabled = false + movingSelection = false + moveDragged = false + sourceText = "" + sourceCarets = emptyList() + } + + fun beginSelectionMove(state: TextFieldState, offset: Int): Boolean { + if (state.readOnly || state.caretRanges.none { it.hasSelection && offset >= it.selectionStart && offset < it.selectionEnd }) return false + movingSelection = true + sourceText = state.text + sourceCarets = state.caretRanges + return true + } + + fun matchesSelection(state: TextFieldState): Boolean = sourceText == state.text && sourceCarets == state.caretRanges + + fun dragSelection(text: String, offset: Int): UiTextCaret? { + if (!dragEnabled) return null + val active = offset.coerceIn(0, text.length) + if (mode == EditableFieldSelectionMode.CHARACTER) return UiTextCaret(active, anchor) + + val activeRange = selectionUnitAt(text, active, mode) + return if (active < anchorRange.start) { + UiTextCaret(activeRange.start, anchorRange.end) + } else { + UiTextCaret(activeRange.end, anchorRange.start) + } + } + + fun clickCount(offset: Int): Int { + val now = System.currentTimeMillis() + val continues = now - lastClickAtMillis <= EditableFieldDoubleClickMillis && abs(lastClickOffset - offset) <= 1 + val count = if (continues) (lastClickCount + 1).coerceAtMost(3) else 1 + lastClickAtMillis = now + lastClickOffset = offset + lastClickCount = count + return count + } +} + +private enum class EditableFieldSelectionMode { + CHARACTER, WORD, LINE, +} + +internal data class EditableTextRange(val start: Int, val end: Int) + +private fun selectionUnitAt(text: String, offset: Int, mode: EditableFieldSelectionMode): EditableTextRange { + return when (mode) { + EditableFieldSelectionMode.CHARACTER -> { + val index = offset.coerceIn(0, text.length) + EditableTextRange(index, index) + } + + EditableFieldSelectionMode.WORD -> wordRangeAt(text, offset) + EditableFieldSelectionMode.LINE -> realLineRangeAt(text, offset) + } +} + +private fun wordRangeAt(text: String, offset: Int): EditableTextRange { + if (text.isEmpty()) return EditableTextRange(0, 0) + val index = offset.coerceIn(0, text.length) + val characterIndex = if (index < text.length) index else text.lastIndex + val character = text[characterIndex] + val predicate: (Char) -> Boolean = when { + character.isEditableFieldWordChar() -> Char::isEditableFieldWordChar + character.isWhitespace() -> Char::isWhitespace + else -> { candidate -> candidate == character } + } + var start = characterIndex + var end = characterIndex + 1 + while (start > 0 && predicate(text[start - 1])) start-- + while (end < text.length && predicate(text[end])) end++ + return EditableTextRange(start, end) +} + +private fun realLineRangeAt(text: String, offset: Int): EditableTextRange { + if (text.isEmpty()) return EditableTextRange(0, 0) + val index = offset.coerceIn(0, text.length) + val start = text.lastIndexOf('\n', (index - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } + val end = text.indexOf('\n', index).let { if (it < 0) text.length else it } + return EditableTextRange(start, end) +} + +private fun Char.isEditableFieldWordChar(): Boolean = this == '_' || isLetterOrDigit() + +private const val EditableFieldDoubleClickMillis = 350L diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSelectionTransfer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSelectionTransfer.kt new file mode 100644 index 000000000..04d170d76 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSelectionTransfer.kt @@ -0,0 +1,33 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +/** Moves the selected fragments together as one undoable edit, in document order. */ +internal fun TextFieldState.transferSelections(target: Int, copy: Boolean): Boolean { + if (readOnly) return false + val ranges = caretRanges.filter { it.hasSelection }.sortedBy { it.selectionStart } + .fold(mutableListOf()) { result, range -> + val previous = result.lastOrNull() + if (previous != null && range.selectionStart < previous.selectionEnd) { + result[result.lastIndex] = + UiTextCaret(maxOf(previous.selectionEnd, range.selectionEnd), previous.selectionStart) + } else result += range + result + } + if (ranges.isEmpty()) return false + val at = target.coerceIn(0, text.length) + if (ranges.any { if (copy) at in it.selectionStart + 1 until it.selectionEnd else at in it.selectionStart..it.selectionEnd }) return false + val selected = + ranges.joinToString(if (multiline) "\n" else " ") { text.substring(it.selectionStart, it.selectionEnd) } + val without = if (copy) text else buildString { + var from = 0 + for (range in ranges) { + append(text, from, range.selectionStart) + from = range.selectionEnd + } + append(text, from, text.length) + } + val removedBefore = + if (copy) 0 else ranges.filter { it.selectionEnd <= at }.sumOf { it.selectionEnd - it.selectionStart } + val insertAt = at - removedBefore + val next = without.substring(0, insertAt) + selected + without.substring(insertAt) + return applyEdit(next, listOf(UiTextCaret(insertAt + selected.length, insertAt))) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSmartIndent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSmartIndent.kt new file mode 100644 index 000000000..9d4d43ebf --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSmartIndent.kt @@ -0,0 +1,73 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +/** + * Deletes before [position]. With a [syntax], a line indented no deeper than its block joins the line above; otherwise + * the indent goes one step at a time. + */ +internal fun indentationBackspaceEdit(text: String, position: Int, size: Int?, syntax: UiCodeSyntax?): TextFieldCodeEdit { + val start = lineStart(text, position) + val prefix = text.substring(start, position) + if (size == null || size <= 0 || prefix.any { it != ' ' && it != '\t' }) { + return TextFieldCodeEdit(position - 1, position, "") + } + if (syntax != null && start > 0 && syntax.isCodeAt(text, position)) { + val structure = TextFieldCodeStructure(text, position, syntax) + val line = text.substring(start, lineEnd(text, position)).trimStart(' ', '\t') + val chainIndent = if (line.startsWith('.') || line.startsWith("?.")) continuationIndent(text, start, size, syntax) else null + val previousIndent = text.substring(0, start).lineSequence().lastOrNull { it.isNotBlank() } + ?.takeWhile { it == ' ' || it == '\t' }.orEmpty() + val normalIndent = chainIndent ?: structure.blockIndent(position, size) ?: previousIndent + if (prefix.indentColumns(size) <= normalIndent.indentColumns(size)) { + val end = lineEnd(text, position) + val blank = text.substring(position, end).all { it == ' ' || it == '\t' } + return TextFieldCodeEdit(start - 1, if (blank) end else position, "") + } + } + return TextFieldCodeEdit(indentationBackspaceStart(text, position, size), position, "") +} + +/** Deletes only the preceding visual indentation step when the indent exceeds its normal level. */ +private fun indentationBackspaceStart(text: String, position: Int, size: Int): Int { + val start = lineStart(text, position) + val prefix = text.substring(start, position) + if (prefix.isEmpty()) return position - 1 + var column = 0 + val columns = prefix.map { char -> column.also { column += if (char == '\t') size - column % size else 1 } } + val target = (column - 1) / size * size + return start + columns.indexOfLast { it <= target }.coerceAtLeast(0) +} + +/** Adds a continuation level only when the dot actually starts a new call-chain link. */ +internal fun continuationDotEdit(text: String, position: Int, size: Int, syntax: UiCodeSyntax): TextFieldCodeEdit? { + val start = lineStart(text, position) + if (start == 0) return null + val prefix = text.substring(start, position) + val safeCall = prefix.trimStart(' ', '\t') == "?" + val indent = if (safeCall) prefix.dropLast(1) else prefix + if (indent.any { it != ' ' && it != '\t' } || !syntax.isCodeAt(text, position)) return null + val desired = continuationIndent(text, start, size, syntax) ?: return null + if (indent.indentColumns(size) >= desired.indentColumns(size)) return null + return TextFieldCodeEdit(start, position, desired + if (safeCall) "?." else ".") +} + +private fun continuationIndent(text: String, start: Int, size: Int, syntax: UiCodeSyntax): String? { + val previousStart = lineStart(text, start - 1) + val previous = text.substring(previousStart, start - 1).trimEnd(' ', '\t') + if (previous.isEmpty()) return null + val previousEnd = previousStart + previous.length + var lastTokenEnd = -1 + fun recordTokenEnd(index: Int) { + if (index in previousStart until previousEnd && !text[index].isWhitespace()) lastTokenEnd = index + } + syntax.scan(text, previousEnd, onCode = ::recordTokenEnd, onLiteralEnd = ::recordTokenEnd) + val ending = text.getOrNull(lastTokenEnd) + if (ending == null || !(ending.isLetterOrDigit() || ending in "_)}]\"'`")) return null + + val previousIndent = previous.takeWhile { it == ' ' || it == '\t' } + val continued = previous.trimStart(' ', '\t').let { it.startsWith('.') || it.startsWith("?.") } + return previousIndent + if (continued) "" else " ".repeat(size) +} + +private fun String.indentColumns(size: Int): Int = fold(0) { column, char -> + column + if (char == '\t') size - column % size else 1 +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldState.kt index e084a368c..85c05d722 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldState.kt @@ -26,6 +26,7 @@ class TextFieldState( var filter: UiTextInputFilter = UiTextInputFilter.ANY, var indentSize: Int? = 4, var autoPairs: Boolean = true, + var syntax: UiCodeSyntax? = null, var multiCaret: Boolean = true, var pasteTransformer: UiTextPasteTransformer? = null, fontSize: Float = DefaultUiFontSize, @@ -39,6 +40,8 @@ class TextFieldState( ) : UndoOwner { val scroll: UiScrollHandle = UiScrollHandle() internal val autoScroll = TextFieldAutoScroll() + internal val dropPreview = TextFieldDropPreview() + internal val gutter = TextFieldGutterCapacity() val folding = TextFieldFolding() var fontSize: Float by mutableStateOf(fontSize) @@ -75,6 +78,9 @@ class TextFieldState( override val history = UndoHistory(TextFieldStateHistoryLimit, mergeWindowNanos = historyMergeWindowNanos, nanoTime = nanoTime) private var restoringHistory = false + /** Told the text an undo or redo put back, for an owner that keeps the text elsewhere too. */ + var onHistoryRestored: ((String) -> Unit)? = null + fun insert(insertion: String): Boolean { if (readOnly || insertion.isEmpty()) return false val normalized = insertion.normalizeEditorLineEndings() @@ -123,36 +129,32 @@ class TextFieldState( ) } - fun typeCharacter(char: Char): Boolean { - if (!autoPairs) return insert(char.toString()) - if (trySkipClosingPair(char)) return true + fun typeCharacter(char: Char): Boolean = typeCodeCharacter(char) - val closing = AutoPairClosings[char] ?: return insert(char.toString()) - if (activeCaretRanges().any { it.hasSelection }) return insert(char.toString()) + fun moveSelections(target: Int, copy: Boolean = false): Boolean = transferSelections(target, copy) - val changed = insert("$char$closing") - if (changed) moveCarets({ range -> range.position - 1 }) - return changed - } + fun previewDropAt(x: Float, y: Float) = dropPreview.track(x, y, offsetAtPoint) + + fun clearDropPreview() = dropPreview.clear() fun backspace(word: Boolean = false): Boolean { if (readOnly) return false if (activeCaretRanges().any { it.hasSelection }) return replaceSelectedRanges("") - if (!word && multiline && deleteWhitespaceOnlyLineBeforeCaret()) return true - if (!word && autoPairs) { - val pairRanges = activeCaretRanges().mapNotNull { range -> - val opening = text.getOrNull(range.position - 1) ?: return@mapNotNull null - val closing = text.getOrNull(range.position) ?: return@mapNotNull null - if (AutoPairClosings[opening] == closing) TextEditRange(range.position - 1, range.position + 1) else null - } - if (pairRanges.size == activeCaretRanges().size && pairRanges.isNotEmpty()) { - return replaceRanges(pairRanges, "") - } - } val ranges = activeCaretRanges().mapNotNull { range -> - if (range.position <= 0) null - else TextEditRange(folding.visibleOffset(if (word) wordLeft(text, range.position) else range.position - 1, - range.position), range.position) + val position = range.position + if (position <= 0) return@mapNotNull null + val opening = text.getOrNull(position - 1) + val closing = text.getOrNull(position) + if (!word && autoPairs && opening != null && closing != null && EditorPairClosings[opening] == closing) { + TextEditRange(position - 1, position + 1) + } else { + val edit = when { + word -> TextFieldCodeEdit(wordLeft(text, position), position, "") + multiline -> indentationBackspaceEdit(text, position, indentSize, syntax) + else -> TextFieldCodeEdit(position - 1, position, "") + } + TextEditRange(folding.visibleOffset(edit.start, position), edit.end) + } } return replaceRanges(ranges, "") } @@ -168,55 +170,21 @@ class TextFieldState( return replaceRanges(ranges, "") } - fun indent(): Boolean { - if (readOnly) return false - val size = indentSize ?: return false - if (!multiline) return false - if (activeCaretRanges().all { !it.hasSelection }) return insert(" ".repeat(size)) + fun indent(): Boolean = indentLines() - val lineStarts = selectedLineStarts() - if (lineStarts.isEmpty()) return false - return replaceRanges(lineStarts.map { TextEditRange(it, it) }, " ".repeat(size)) - } + fun unindent(): Boolean = unindentLines() - fun unindent(): Boolean { - if (readOnly) return false - val size = indentSize ?: return false - if (!multiline) return false - val ranges = selectedLineStarts().mapNotNull { start -> - val end = (start + size).coerceAtMost(text.length) - val removable = text.substring(start, end).takeWhile { it == ' ' }.length - removable.takeIf { it > 0 }?.let { TextEditRange(start, start + it) } - } - return replaceRanges(ranges, "") - } + fun insertNewlineWithIndent(): Boolean = newlineWithIndent() - fun insertNewlineWithIndent(): Boolean { - if (readOnly || !multiline) return false - val size = indentSize ?: return insert("\n") - if (activeCaretRanges().any { it.hasSelection }) return insert("\n") - - val edits = activeCaretRanges().map { newlineEdit(text, it.position, size) } - .distinctBy { it.start to it.end }.sortedBy { it.start } - if (edits.zipWithNext().any { (left, right) -> right.start < left.end }) return false - val carets = ArrayList() - val next = buildString { - var cursor = 0 - for (edit in edits) { - append(text, cursor, edit.start) - carets += UiTextCaret(length + edit.caretOffset) - append(edit.replacement) - cursor = edit.end - } - append(text, cursor, text.length) - } - return applyEdit(next, carets) - } /** * Applies a programmatic edit (e.g. accepting a completion item) as a single undoable step: * the whole next text plus the carets it should leave behind. + * + * @param label what the step is called in the history window; typing when null. + * @param mergeKey folds the step into the one before when that carries the same key and came shortly before, + * as for the letters typed into one field; with none the step stands alone. */ - fun applyEdit(next: String, carets: List): Boolean { + fun applyEdit(next: String, carets: List, label: UndoLabel? = null, mergeKey: String? = null): Boolean { if (readOnly) return false val normalized = next.normalizeEditorLineEndings() if (!filter.accepts(normalized)) return false @@ -224,11 +192,11 @@ class TextFieldState( setCarets(carets) return false } - breakHistoryGroup() - recordHistorySnapshot() + if (mergeKey == null) breakHistoryGroup() + recordHistorySnapshot(label ?: TypingLabel, mergeKey) text = normalized setCaretsAndReveal(carets) - breakHistoryGroup() + if (mergeKey == null) breakHistoryGroup() return true } @@ -466,45 +434,9 @@ class TextFieldState( private fun activeCaretRanges(): List = if (multiCaret) caretRanges else caretRanges.take(1) - private fun trySkipClosingPair(char: Char): Boolean { - if (char !in AutoPairClosings.values) return false - if (activeCaretRanges().any { it.hasSelection || text.getOrNull(it.position) != char }) return false - moveCarets({ range -> range.position + 1 }) - return true - } - - private fun deleteWhitespaceOnlyLineBeforeCaret(): Boolean { - val position = caret.coerceIn(0, text.length) - val lineStart = text.lastIndexOf('\n', (position - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } - val lineEnd = text.indexOf('\n', position).let { if (it < 0) text.length else it } - if (lineEnd <= lineStart) return false - if (text.substring(lineStart, lineEnd).any { it != ' ' }) return false - - val deleteStart = if (lineStart > 0) lineStart - 1 else 0 - val deleteEnd = if (lineStart == 0 && lineEnd < text.length) lineEnd + 1 else lineEnd - return replaceRanges(listOf(TextEditRange(deleteStart, deleteEnd)), "") - } - - private fun selectedLineStarts(): List { - val starts = linkedSetOf() - for (range in activeCaretRanges()) { - val from = range.selectionStart.coerceIn(0, text.length) - val rawTo = range.selectionEnd.coerceIn(0, text.length) - val to = if (rawTo > from && text.getOrNull(rawTo - 1) == '\n') rawTo - 1 else rawTo - var lineStart = text.lastIndexOf('\n', (from - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } - while (lineStart <= to) { - starts += lineStart - val next = text.indexOf('\n', lineStart) - if (next < 0 || next >= to) break - lineStart = next + 1 - } - } - return starts.toList() - } - - private fun recordHistorySnapshot() { + private fun recordHistorySnapshot(label: UndoLabel = TypingLabel, mergeKey: String? = TypingMergeKey) { if (restoringHistory) return - history.record(SnapshotStep(this, TypingLabel, historyEntry(), ::historyEntry, ::restoreHistory), TypingMergeKey) + history.record(SnapshotStep(this, label, historyEntry(), ::historyEntry, ::restoreHistory), mergeKey) } private fun breakHistoryGroup() = history.breakMerge() @@ -520,6 +452,7 @@ class TextFieldState( } finally { restoringHistory = false } + onHistoryRestored?.invoke(text) } private data class TextEditRange(val start: Int, val end: Int) @@ -534,10 +467,3 @@ private const val TextFieldStateHistoryLimit = 128 private const val TextFieldStateHistoryMergeWindowNanos = 600_000_000L private const val TypingMergeKey = "typing" private val TypingLabel = UndoLabel("${UndoLabel.LANG}.typing") -private val AutoPairClosings = mapOf( - '(' to ')', - '[' to ']', - '{' to '}', - '"' to '"', - '\'' to '\'', -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeEditor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeEditor.kt index daf79ae45..70035f502 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeEditor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeEditor.kt @@ -4,26 +4,21 @@ import androidx.compose.runtime.Composable import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.style.DefaultUiFontSize import ru.hollowhorizon.hollowengine.client.ui.text.Shadow -import ru.hollowhorizon.hollowengine.common.scripting.ide.CodeFold +/** + * The editor of a code file: a multi-caret field with line numbers, folds, markers and indent guides, which indents + * and pairs delimiters as it is typed in. + * + * @param syntax the language of the code, which typing reads blocks and literals by; null edits it as plain text. + */ @Composable fun UiCodeEditor( value: String, onChange: (String) -> Unit, - highlighter: UiSyntaxHighlighter? = null, - completions: UiCompletionContributor? = null, - signatureHelp: UiSignatureHelpProvider? = null, - hoverInfo: UiHoverInfoProvider? = null, - diagnostics: List = emptyList(), - folds: List = emptyList(), - gutterMarkers: List = emptyList(), - searchMatches: List = emptyList(), - activeSearchMatch: IntRange? = null, - inlayHints: List = emptyList(), - inlayHintsProvider: UiInlayHintsProvider? = null, - inlayRevision: Long = 0L, - onInlayAction: ((UiInlayAction) -> Unit)? = null, - completionRevision: Long = inlayRevision, + insight: UiCodeInsight = UiCodeInsight(), + view: UiCodeView = UiCodeView(), + search: UiTextSearch? = null, + syntax: UiCodeSyntax? = null, readOnly: Boolean = false, fontSize: Float = DefaultUiFontSize, fontFamily: String? = UiCodeFontFamily, @@ -36,37 +31,22 @@ fun UiCodeEditor( ) { TextField( value = value, + onChange = onChange, mode = UiTextFieldMode.MULTI_LINE, - multiCaret = true, - syntaxHighlighter = highlighter, - completionContributor = completions, - signatureHelpProvider = signatureHelp, - hoverInfoProvider = hoverInfo, - codeInsightRevision = completionRevision, - indentSize = 4, - autoPairs = true, readOnly = readOnly, - diagnostics = diagnostics, - folds = folds, - gutterMarkers = gutterMarkers, - searchMatches = searchMatches, - activeSearchMatch = activeSearchMatch, - inlayHints = inlayHints, - inlayHintsProvider = inlayHintsProvider, - inlayRevision = inlayRevision, - onInlayAction = onInlayAction, - completionRevision = completionRevision, + editing = UiCodeEditing(syntax = syntax), + insight = insight, + view = view.copy( + lineNumberColor = view.lineNumberColor ?: CodeEditorLineNumberColor, + indentGuideColor = view.indentGuideColor ?: CodeEditorIndentGuideColor, + ), + search = search, wrap = false, fontSize = fontSize, fontFamily = fontFamily, textShadow = textShadow, caretColor = CodeEditorCaretColor, selectionColor = CodeEditorSelectionColor, - lineNumbers = true, - lineNumberColor = CodeEditorLineNumberColor, - indentGuides = true, - indentGuideColor = CodeEditorIndentGuideColor, - onChange = onChange, state = state, id = id, tags = listOf("code-editor") + tags, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeFieldOptions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeFieldOptions.kt new file mode 100644 index 000000000..c6128b5ac --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeFieldOptions.kt @@ -0,0 +1,61 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.common.scripting.ide.CodeFold + +/** + * How typing reshapes the text of a field that holds code. + * + * @param indentSize the indent step in spaces. + * @param autoPairs whether an opening delimiter or quote adds its closing one. + * @param syntax the language typing reads blocks, call chains and literals by; null edits plain text. + */ +data class UiCodeEditing( + val indentSize: Int = 4, + val autoPairs: Boolean = true, + val multiCaret: Boolean = true, + val syntax: UiCodeSyntax? = null, +) + +/** + * What a field offers on top of its text: highlighting, completion, signatures and hover, problems and inlay hints. + * + * @param completionRevision changes when completions change while the text does not. + * @param revision the same for signatures and hover. + * @param inlayRevision the same for the hints of [inlayHintsProvider]. + * @param highlightRevision the same for the colors of [highlighter], which stay on screen until the new ones are ready. + * @param diagnosticTooltips whether hovering a problem tells of it; a field that tells of them itself turns this off. + */ +data class UiCodeInsight( + val highlighter: UiSyntaxHighlighter? = null, + val completions: UiCompletionContributor? = null, + val signatureHelp: UiSignatureHelpProvider? = null, + val hover: UiHoverInfoProvider? = null, + val diagnostics: List = emptyList(), + val inlayHints: List = emptyList(), + val inlayHintsProvider: UiInlayHintsProvider? = null, + val onInlayAction: ((UiInlayAction) -> Unit)? = null, + val completionRevision: Long = 0L, + val revision: Long = completionRevision, + val inlayRevision: Long = 0L, + val highlightRevision: Long = 0L, + val diagnosticTooltips: Boolean = true, +) + +/** + * How a field shows lines of code: the gutter with line numbers, folds and markers, and indent guides. A null color + * is the one the field draws with by default. + */ +data class UiCodeView( + val folds: List = emptyList(), + val markers: List = emptyList(), + val lineNumberColor: UiColor? = null, + val indentGuides: Boolean = true, + val indentGuideColor: UiColor? = null, +) + +/** Search hits to mark in the text, and the one the search is on. */ +data class UiTextSearch( + val matches: List = emptyList(), + val active: IntRange? = null, +) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeSyntax.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeSyntax.kt new file mode 100644 index 000000000..89ddce950 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCodeSyntax.kt @@ -0,0 +1,23 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +/** + * The syntax code editing reads from a field's text: where code is, as opposed to literals and comments, and which + * lines a string literal runs through, whose indent belongs to the string. A field without one edits plain text: it + * keeps the indent of the line and pairs delimiters, but knows no blocks, call chains or literals. + */ +interface UiCodeSyntax { + /** + * Reads [text] up to [limit] and returns whether [limit] is in code. [onCode] gets each offset that is code, + * [onLiteralEnd] the last offset of each literal, and [onLineStart] each line start with whether a string runs + * through it. + */ + fun scan( + text: String, + limit: Int, + onCode: ((Int) -> Unit)? = null, + onLineStart: ((offset: Int, inString: Boolean) -> Unit)? = null, + onLiteralEnd: ((Int) -> Unit)? = null, + ): Boolean +} + +internal fun UiCodeSyntax.isCodeAt(text: String, offset: Int): Boolean = scan(text, offset) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCompletionPreview.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCompletionPreview.kt new file mode 100644 index 000000000..1c7cfc6fb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiCompletionPreview.kt @@ -0,0 +1,36 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect + +interface UiCompletionPreview { + @Composable + fun Content() +} + +private val PreviewAlignment = UiPopupAlignment( + anchorHorizontal = UiAlign.START, + anchorVertical = UiAlign.START, + popupHorizontal = UiAlign.END, + offsetX = -PREVIEW_GAP, + flipsHorizontally = true, +) + +private const val PREVIEW_GAP = 4f + +@Composable +internal fun CompletionPreviewPopup(preview: UiCompletionPreview, anchor: UiRect) { + Popup( + anchorBounds = anchor, + alignment = PreviewAlignment, + id = "editable-text-field-completion-preview", + tags = listOf("ide-completion-popup", "ide-completion-preview"), + modifier = Modifier.inputTransparent(), + dismissOnOutside = false, + animated = false, + ) { + key(preview) { preview.Content() } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiDropdown.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiDropdown.kt index 96ab8f7eb..ba4f3db67 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiDropdown.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiDropdown.kt @@ -1,6 +1,8 @@ package ru.hollowhorizon.hollowengine.client.ui.widgets import androidx.compose.runtime.* +import kotlinx.coroutines.delay +import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect @@ -19,9 +21,15 @@ data class UiDropdownSlider( val onCommit: ((Float) -> Unit)? = null, ) +/** + * One line of a menu. + * + * @param iconColor what [icon] is tinted in, when it stands for something with a color of its own. + */ data class UiDropdownItem( val label: String, val icon: String? = null, + val iconColor: UiColor? = null, val enabled: Boolean = true, val checked: Boolean = false, val mark: UiDropdownMark? = null, @@ -66,6 +74,11 @@ fun UiDropdown( ContextMenu(id, anchorBounds, items, onExpandedChange = onExpandedChange) } +private const val SearchIcon = "hollowengine:textures/gui/icons/search.svg" + +/** How long the pointer may rest on its way to an open submenu before the item under it takes over. */ +private const val SubmenuIntentMillis = 300L + /** How much of the height of the screen a menu may take before it scrolls. */ private const val MenuHeightShare = 0.8f @@ -74,8 +87,15 @@ private val SubmenuAlignment = UiPopupAlignment( anchorVertical = UiAlign.START, offsetX = 6f, offsetY = -4f, + flipsHorizontally = true, ) +/** + * A menu at [anchorBounds]. + * + * @param search the hint of a field on top that finds items through every submenu as it is typed; null leaves it out. + * @param onBounds told where the menu lies once it is placed. + */ @Composable fun ContextMenu( id: String, @@ -83,22 +103,113 @@ fun ContextMenu( items: List, alignment: UiPopupAlignment = UiPopupAlignment.BelowStart, layer: Int = 0, + search: String? = null, + onBounds: (UiRect) -> Unit = {}, onExpandedChange: (Boolean) -> Unit = {}, ) { val anyLeading = items.any { it.icon != null || it.mark != null } val maxHeight = LocalUiViewport.current.height * MenuHeightShare var openSubmenu by remember { mutableStateOf(null) } var anchors by remember { mutableStateOf(emptyMap()) } + val intent = remember { UiSubmenuIntent() } + var pending by remember { mutableStateOf(null) } + + fun submenuOf(index: Int): Int? = index.takeIf { items.getOrNull(it)?.let { item -> item.children != null && item.enabled } == true } + + fun show(index: Int) { + val submenu = submenuOf(index) + if (submenu == openSubmenu) return + openSubmenu = submenu + intent.submenu = null + } + + fun hover(index: Int, event: UiEvent) { + when { + index == openSubmenu -> { + intent.track(event.x, event.y) + pending = null + } + + openSubmenu != null && intent.aims(event.x, event.y) -> pending = index + else -> { + pending = null + show(index) + } + } + } + + LaunchedEffect(pending) { + val index = pending ?: return@LaunchedEffect + delay(SubmenuIntentMillis) + pending = null + show(index) + } + var query by remember { mutableStateOf("") } + var picked by remember { mutableStateOf(0) } + val found = remember(items, query) { if (search == null || query.isBlank()) null else UiMenuSearch.find(items, query) } Popup( anchorBounds = anchorBounds, alignment = alignment, layer = layer, id = "$id-popup", tags = listOf("dropdown-popup"), - modifier = Modifier.maxSize(height = maxHeight.px).scrollable(horizontal = false), + modifier = Modifier.maxSize(height = maxHeight.px).scrollable(horizontal = false).onPlaced(onBounds), onDismiss = { onExpandedChange(false) }, ) { - items.forEachIndexed { index, item -> + if (search != null) { + Row(tags = listOf("dropdown-search"), modifier = Modifier.alignItems(vertical = UiAlign.CENTER)) { + Image(SearchIcon, tags = listOf("dropdown-search-icon")) + TextField( + value = query, + placeholder = search, + onChange = { + query = it + picked = 0 + openSubmenu = null + }, + id = "$id-search", + tags = listOf("dropdown-search-input"), + modifier = Modifier.size(0.px, UiLength.Auto).grow(1f).onKeyInput { input -> + val shown = found.orEmpty() + when (input.key) { + GLFW.GLFW_KEY_DOWN -> picked = (picked + 1).coerceAtMost((shown.size - 1).coerceAtLeast(0)) + GLFW.GLFW_KEY_UP -> picked = (picked - 1).coerceAtLeast(0) + GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> shown.getOrNull(picked)?.let { match -> + dismiss() + match.item.onClick() + } + + GLFW.GLFW_KEY_ESCAPE -> dismiss() + + else -> return@onKeyInput + } + input.consume() + }, + ) + } + val focus = LocalUiFocusRequester.current + LaunchedEffect(Unit) { focus.request("$id-search") } + } + if (found != null) { + val leading = found.any { it.item.icon != null } + found.forEachIndexed { index, match -> + Row( + id = "$id-found-$index", + tags = listOfNotNull("dropdown-item", "picked".takeIf { index == picked }), + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .alignItems(vertical = UiAlign.CENTER) + .onClick { event -> + dismiss() + match.item.onClick() + event.consume() + }, + ) { + MenuIcon(match.item, leading) + Text(match.item.label, tags = listOf("dropdown-item-label")) + if (match.path.isNotEmpty()) Text(match.path, tags = listOf("dropdown-item-shortcut")) + } + } + } else items.forEachIndexed { index, item -> if (item.separatorBefore && index > 0) Box(tags = listOf("dropdown-separator")) if (item.slider != null) { DropdownSliderRow("$id-item-$index", item.label, item.slider) { openSubmenu = null } @@ -126,7 +237,9 @@ fun ContextMenu( modifier = Modifier.input(hoverable = item.enabled, clickable = item.enabled) .cursor(if (item.enabled) UiCursorShape.HAND else UiCursorShape.DEFAULT) .alignItems(vertical = UiAlign.CENTER) - .onEnter { openSubmenu = if (children != null && item.enabled) index else null } + .onEnter { hover(index, it) } + .onHover { hover(index, it) } + .onExit { if (pending == index) pending = null } .let { if (children != null) it.onPlaced { rect -> if (anchors[index] != rect) anchors = anchors + (index to rect) } else it } .onClick(itemAction) ) { @@ -140,10 +253,8 @@ fun ContextMenu( tags = listOf("dropdown-item-check"), modifier = Modifier.onClick(itemAction), ) - } else if (item.icon != null) { - Image(item.icon, tags = listOf("dropdown-item-icon")) - } else if (anyLeading) { - Box(modifier = Modifier.size(16.px, 16.px)) + } else { + MenuIcon(item, anyLeading) } Text(item.label, tags = listOf("dropdown-item-label")) if (item.shortcut != null) Text(item.shortcut, tags = listOf("dropdown-item-shortcut")) @@ -163,10 +274,25 @@ fun ContextMenu( onExpandedChange = onExpandedChange, alignment = SubmenuAlignment, layer = layer + 1, + onBounds = { intent.submenu = it }, ) } } +/** The icon of [item] in its own color, or the room for one when [leading] says other items of the menu have one. */ +@Composable +private fun MenuIcon(item: UiDropdownItem, leading: Boolean) { + when { + item.icon != null -> Image( + item.icon, + tags = listOf("dropdown-item-icon"), + modifier = item.iconColor?.let { Modifier.tint(it) }, + ) + + leading -> Box(modifier = Modifier.size(16.px, 16.px)) + } +} + @Composable private fun DropdownSliderRow(id: String, label: String, slider: UiDropdownSlider, onEnter: () -> Unit) { val last = remember(id) { floatArrayOf(slider.value) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextField.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextField.kt index 1046f8496..fcddd6ce3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextField.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextField.kt @@ -485,6 +485,10 @@ private fun nearestColumn(text: String, x: Float, fontSize: Float, fontFamily: S * A text field measured and rendered with one consistent layout ([EditableFieldLayout]): a single * sized content box holds only the rows in the scrolled viewport (manual virtualization), each row * carrying its own glyphs plus that line's selection and caret(s). Editing lives in [TextFieldState]. + * + * @param insight what the field offers on top of its text. + * @param view the gutter and indent guides of a code field; null shows the text alone. + * @param search the search hits to mark. */ @Composable fun EditableTextField( @@ -493,42 +497,31 @@ fun EditableTextField( id: String? = null, tags: Iterable = emptyList(), scrollState: UiScrollHandle = state.scroll, - syntaxHighlighter: UiSyntaxHighlighter? = null, - inlayHints: List = emptyList(), - inlayHintsProvider: UiInlayHintsProvider? = null, - inlayRevision: Long = 0L, - onInlayAction: ((UiInlayAction) -> Unit)? = null, - completionContributor: UiCompletionContributor? = null, - completionRevision: Long = 0L, - signatureHelpProvider: UiSignatureHelpProvider? = null, - hoverInfoProvider: UiHoverInfoProvider? = null, - codeInsightRevision: Long = completionRevision, - diagnostics: List = emptyList(), - searchMatches: List = emptyList(), - activeSearchMatch: IntRange? = null, + insight: UiCodeInsight = UiCodeInsight(), + view: UiCodeView? = null, + search: UiTextSearch? = null, placeholder: String = "", - lineNumbers: Boolean = false, - folds: List = emptyList(), - gutterMarkers: List = emptyList(), - lineNumberColor: UiColor = EditableFieldLineNumberColor, - indentGuides: Boolean = false, - indentGuideColor: UiColor = EditableFieldIndentGuideColor, attributes: Map = emptyMap(), ) { + val diagnostics = insight.diagnostics + val searchMatches = search?.matches.orEmpty() + val activeSearchMatch = search?.active + val folds = view?.folds.orEmpty() + val gutterMarkers = view?.markers.orEmpty() val text = state.text val fontSize = state.fontSize val fontFamily = state.fontFamily val wrap = state.wrap - // Occurrence highlight follows the caret only while the field is focused. val caret = if (state.focused) state.caret else UiNoCaretOffset state.caretVisibilityRevision val presentation = rememberEditableTextPresentation( text = text, caret = caret, - highlighter = syntaxHighlighter, - inlayHints = inlayHints, - inlayHintsProvider = inlayHintsProvider, - inlayRevision = inlayRevision, + highlighter = insight.highlighter, + inlayHints = insight.inlayHints, + inlayHintsProvider = insight.inlayHintsProvider, + inlayRevision = insight.inlayRevision, + highlightRevision = insight.highlightRevision, ) state.folding.update(text, folds) val foldProjection = state.folding.projection() @@ -536,9 +529,9 @@ fun EditableTextField( val displayedHints = presentation.inlayHints + foldProjection.placeholders val uiViewport = LocalUiViewport.current val actionGutterWidth = remember(text, fontSize, folds, gutterMarkers) { - editableFieldActionGutterWidth(folds, gutterMarkers, foldProjection.lines, fontSize) + state.gutter.width(gutterMarkers, foldProjection.lines, fontSize) } - val gutterPanelWidth = if (lineNumbers && state.multiline) { + val gutterPanelWidth = if (view != null && state.multiline) { editableFieldGutterWidth(text, fontSize) + actionGutterWidth } else { 0f @@ -576,10 +569,16 @@ fun EditableTextField( var hoverTooltip by remember { mutableStateOf(null) } var codeHoverTarget by remember { mutableStateOf(null) } val pointerState = remember { EditableFieldPointerState() } + DisposableEffect(state) { + onDispose { + state.offsetAtPoint = null + state.clearDropPreview() + pointerState.cancelDrag() + } + } SideEffect { - completion.contributor = completionContributor - state.autoScroll.follow(state, layout, scrollState, gutterWidth) - completion.onFrame(completionRevision) + completion.contributor = insight.completions + completion.onFrame(insight.completionRevision) state.offsetAtPoint = { rootX, rootY -> val viewport = scrollState.viewport layout.offsetAt( @@ -587,6 +586,8 @@ fun EditableTextField( rootY - viewport.y + scrollState.offsetY, ) } + state.dropPreview.refresh(state.offsetAtPoint) + state.autoScroll.follow(state, layout, scrollState, gutterWidth) } val visible = layout.visibleRange(scrollState.offsetY, scrollState.viewport.height, overscan = fontSize * 4f) @@ -596,11 +597,14 @@ fun EditableTextField( tags = listOf("text-field", "editable-text-field") + tags, mode = UiBoxMode.STACK, attributes = attributes, - modifier = Modifier.focus().cursor(UiCursorShape.TEXT).whitespace(UiWhitespace.PRESERVE).fontSize(fontSize) + modifier = Modifier.focus().input(hoverable = true, clickable = true, draggable = true) + .cursor(UiCursorShape.TEXT).whitespace(UiWhitespace.PRESERVE).fontSize(fontSize) .let { if (fontFamily != null) it.fontFamily(fontFamily) else it }.onFocus { state.focus() }.onUnfocus { state.unfocus() completion.close() codeInsight.clearSignature() + pointerState.cancelDrag() + state.clearDropPreview() }.onCharTyped { event -> val char = event.codePoint.toChar() if (event.codePoint != 0 && !char.isISOControl() && state.typeCharacter(char)) { @@ -610,16 +614,19 @@ fun EditableTextField( } // Low priority: user onKeyInput handlers (default priority 0) get first refusal. .onKeyInput(TextFieldDefaultKeyPriority) { input -> + if (input.key == GLFW.GLFW_KEY_ESCAPE && pointerState.movingSelection) { + pointerState.cancelDrag() + state.clearDropPreview() + input.consume() + return@onKeyInput + } val handled = handleEditableFieldIntentionKey(state, input, completion, diagnostics) || handleEditableFieldKey( - state, - input, - layout, - completion, - codeInsight + state, input, layout, completion, codeInsight ) if (handled || !state.readOnly && input.typesCharacter()) input.consume() }.onPress { event -> + state.clearDropPreview() state.focus() val hit = clickHit(event, layout, gutterWidth) val clickCount = pointerState.clickCount(hit.offset) @@ -627,11 +634,21 @@ fun EditableTextField( pointerState.beginPress(state.text, hit.offset, clickCount, altPressed) handleEditableFieldPress( state, hit.offset, clickCount, event.modifiers, pointerState, hit.inlayAffinity, + allowSelectionMove = event.isLeftClick(), ) }.onDrag { event -> + if (!event.isLeftClick()) return@onDrag + if (pointerState.movingSelection && abs(event.dragTotalX) < 3f && abs(event.dragTotalY) < 3f) return@onDrag handleEditableFieldDrag(state, clickOffset(event, layout, gutterWidth), pointerState) + if (pointerState.movingSelection) state.previewDropAt(event.rootLocalX, event.rootLocalY) + }.onRelease { event -> + if (!event.isLeftClick()) return@onRelease + val offset = state.offsetAtPoint?.invoke(event.rootLocalX, event.rootLocalY) ?: state.caret + val hit = event.frame?.hitTest(event.x, event.y)?.node + val inside = generateSequence(hit) { it.layoutState.parentNode }.any { it === event.node } + if (handleEditableFieldRelease(state, offset, event.modifiers, pointerState, inside)) event.consume() }.onHover { event -> - hoverTooltip = if (diagnostics.isEmpty()) null else { + hoverTooltip = if (diagnostics.isEmpty() || !insight.diagnosticTooltips) null else { val viewport = scrollState.viewport val surface = uiViewport.takeIf { it.width > 0f && it.height > 0f } ?: viewport editableFieldDiagnosticTooltipAt( @@ -648,7 +665,7 @@ fun EditableTextField( originY = viewport.y - surface.y, ) } - codeHoverTarget = if (hoverTooltip == null && hoverInfoProvider != null) { + codeHoverTarget = if (hoverTooltip == null && insight.hover != null) { EditableFieldHoverTarget(clickHit(event, layout, gutterWidth).offset) } else { null @@ -694,11 +711,11 @@ fun EditableTextField( layout = layout, rowDiagnostics = diagnosticsByLine.getOrNull(index).orEmpty(), rowSearchMatches = matchesByLine.getOrNull(index).orEmpty(), - indentGuides = indentGuides, - indentGuideColor = indentGuideColor, + indentGuides = view?.indentGuides == true, + indentGuideColor = view?.indentGuideColor ?: EditableFieldIndentGuideColor, inlayMetrics = inlayMetrics, onInlayAction = { action -> - if (!state.folding.activate(action)) onInlayAction?.invoke(action) + if (!state.folding.activate(action)) insight.onInlayAction?.invoke(action) }, ) } @@ -709,12 +726,13 @@ fun EditableTextField( layout, visible, gutterPanelWidth, - lineNumberColor, + layout.height.coerceAtLeast(scrollState.viewport.height), + view?.lineNumberColor ?: EditableFieldLineNumberColor, state, folds, gutterMarkers, id ?: "text-field-${System.identityHashCode(state)}", - indentGuideColor + view?.indentGuideColor ?: EditableFieldIndentGuideColor ) } if (completion.items.isNotEmpty()) { @@ -730,9 +748,9 @@ fun EditableTextField( focused = state.focused, completionVisible = completion.items.isNotEmpty(), hoverTarget = codeHoverTarget, - signatureProvider = signatureHelpProvider, - hoverProvider = hoverInfoProvider, - revision = codeInsightRevision, + signatureProvider = insight.signatureHelp, + hoverProvider = insight.hover, + revision = insight.revision, layout = layout, scrollState = scrollState, contentOffsetX = gutterWidth, @@ -746,155 +764,6 @@ internal fun editableFieldGutterWidth(text: String, fontSize: Float): Float { return digits * fontSize * 0.62f + EditableFieldLineNumberGap } -internal fun handleEditableFieldPress( - state: TextFieldState, - offset: Int, - clickCount: Int, - modifiers: Int, - pointerState: EditableFieldPointerState = EditableFieldPointerState().also { - it.beginPress(state.text, offset, clickCount, modifiers and GLFW.GLFW_MOD_ALT != 0) - }, - inlayAffinity: UiInlayCaretAffinity = UiInlayCaretAffinity.AFTER, -) { - val altPressed = modifiers and GLFW.GLFW_MOD_ALT != 0 - when { - clickCount >= 3 -> toggleOrAddPointerSelection(state, pointerState, offset, altPressed) - clickCount == 2 -> toggleOrAddPointerSelection(state, pointerState, offset, altPressed) - altPressed && state.removeCaretRangeAt(offset) -> pointerState.cancelDrag() - altPressed -> state.addCaret(offset, inlayAffinity) - else -> state.moveCaret(offset, inlayAffinity = inlayAffinity) - } -} - -internal fun handleEditableFieldDrag(state: TextFieldState, offset: Int, pointerState: EditableFieldPointerState) { - val selection = pointerState.dragSelection(state.text, offset) ?: return - applyPointerSelection(state, selection, pointerState.altPressed) -} - -private fun toggleOrAddPointerSelection( - state: TextFieldState, - pointerState: EditableFieldPointerState, - offset: Int, - altPressed: Boolean, -) { - val selection = pointerState.dragSelection(state.text, offset) ?: return - if (altPressed && state.removeCaretRange(selection)) { - pointerState.cancelDrag() - return - } - addPointerSelection(state, selection, altPressed) -} - -private fun addPointerSelection(state: TextFieldState, selection: UiTextCaret?, altPressed: Boolean) { - selection ?: return - val anchor = selection.selectionAnchor ?: selection.position - if (altPressed) state.addCaretRange(UiTextCaret(selection.position, anchor)) - else state.setSelection(anchor, selection.position) -} - -private fun applyPointerSelection(state: TextFieldState, selection: UiTextCaret?, altPressed: Boolean) { - selection ?: return - val anchor = selection.selectionAnchor ?: selection.position - if (altPressed) state.updateLastCaretRange(anchor, selection.position) - else state.setSelection(anchor, selection.position) -} - -internal class EditableFieldPointerState { - var altPressed = false - private set - - private var anchor = 0 - private var mode = EditableFieldSelectionMode.CHARACTER - private var anchorRange = EditableTextRange(0, 0) - private var dragEnabled = false - private var lastClickAtMillis = 0L - private var lastClickOffset = -1 - private var lastClickCount = 0 - - fun beginPress(text: String, offset: Int, clickCount: Int, altPressed: Boolean) { - this.altPressed = altPressed - anchor = offset.coerceIn(0, text.length) - mode = when { - clickCount >= 3 -> EditableFieldSelectionMode.LINE - clickCount == 2 -> EditableFieldSelectionMode.WORD - else -> EditableFieldSelectionMode.CHARACTER - } - anchorRange = selectionUnitAt(text, anchor, mode) - dragEnabled = true - } - - fun cancelDrag() { - dragEnabled = false - } - - fun dragSelection(text: String, offset: Int): UiTextCaret? { - if (!dragEnabled) return null - val active = offset.coerceIn(0, text.length) - if (mode == EditableFieldSelectionMode.CHARACTER) return UiTextCaret(active, anchor) - - val activeRange = selectionUnitAt(text, active, mode) - return if (active < anchorRange.start) { - UiTextCaret(activeRange.start, anchorRange.end) - } else { - UiTextCaret(activeRange.end, anchorRange.start) - } - } - - fun clickCount(offset: Int): Int { - val now = System.currentTimeMillis() - val continues = now - lastClickAtMillis <= EditableFieldDoubleClickMillis && abs(lastClickOffset - offset) <= 1 - val count = if (continues) (lastClickCount + 1).coerceAtMost(3) else 1 - lastClickAtMillis = now - lastClickOffset = offset - lastClickCount = count - return count - } -} - -private enum class EditableFieldSelectionMode { - CHARACTER, WORD, LINE, -} - -internal data class EditableTextRange(val start: Int, val end: Int) - -private fun selectionUnitAt(text: String, offset: Int, mode: EditableFieldSelectionMode): EditableTextRange { - return when (mode) { - EditableFieldSelectionMode.CHARACTER -> { - val index = offset.coerceIn(0, text.length) - EditableTextRange(index, index) - } - - EditableFieldSelectionMode.WORD -> wordRangeAt(text, offset) - EditableFieldSelectionMode.LINE -> realLineRangeAt(text, offset) - } -} - -private fun wordRangeAt(text: String, offset: Int): EditableTextRange { - if (text.isEmpty()) return EditableTextRange(0, 0) - val index = offset.coerceIn(0, text.length) - val characterIndex = if (index < text.length) index else text.lastIndex - val character = text[characterIndex] - val predicate: (Char) -> Boolean = when { - character.isEditableFieldWordChar() -> Char::isEditableFieldWordChar - character.isWhitespace() -> Char::isWhitespace - else -> { candidate -> candidate == character } - } - var start = characterIndex - var end = characterIndex + 1 - while (start > 0 && predicate(text[start - 1])) start-- - while (end < text.length && predicate(text[end])) end++ - return EditableTextRange(start, end) -} - -private fun realLineRangeAt(text: String, offset: Int): EditableTextRange { - if (text.isEmpty()) return EditableTextRange(0, 0) - val index = offset.coerceIn(0, text.length) - val start = text.lastIndexOf('\n', (index - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } - val end = text.indexOf('\n', index).let { if (it < 0) text.length else it } - return EditableTextRange(start, end) -} - -private fun Char.isEditableFieldWordChar(): Boolean = this == '_' || isLetterOrDigit() @Composable private fun EditableFieldRow( @@ -988,7 +857,16 @@ private fun EditableFieldRow( EditableFieldRowDiagnostics(line, lineLayout, rowDiagnostics, top, fontSize, fontFamily, layout.contentWidth) } - if (state.focused) key(state.caretVisibilityRevision) { + state.dropPreview.offset?.takeIf { it in line.start..line.end }?.let { offset -> + val dropCaret = layout.caretAt(offset) + Box( + tags = listOf("editable-text-field-drop-caret"), + modifier = Modifier.position(dropCaret.x.px, dropCaret.y.px) + .size(TextFieldCaretWidth.px, layout.lineHeight.px).background(state.caretColor).layer(1) + .inputTransparent() + ) + } + if (state.focused && state.dropPreview.offset == null) key(state.caretVisibilityRevision) { ranges.filter { it.position in line.start..line.end }.forEachIndexed { caretIndex, range -> val caret = layout.caretAt(range.position, range.inlayAffinity) key("caret", caretIndex) { @@ -1169,20 +1047,21 @@ internal fun selectionRectsForRow( @Suppress("UNUSED_PARAMETER") fullWidth: Float, ): List { val newlineWidth = UiTextLayouter.measureTextWidth(" ", fontSize, fontFamily).coerceAtLeast(1f) + val fontHeight = UiTextFonts.resolve(fontFamily).lineHeight(fontSize) if (lineLayout != null) { val rects = mergeSelectionRects( lineLayout.selectionRects(localStart, localEnd, fontSize, fontFamily, fillLineGaps = false), ) if (!crossesNewline) return rects val caret = lineLayout.caretPosition(localEnd, fontSize, fontFamily) - val lineHeight = lineLayout.lines.lastOrNull()?.height ?: fontSize + val lineHeight = lineLayout.lines.lastOrNull()?.height ?: fontHeight return rects + UiRect(caret.x, caret.y, newlineWidth, lineHeight) } val x1 = UiTextLayouter.measureTextWidth(line.text.take(localStart), fontSize, fontFamily) val x2 = UiTextLayouter.measureTextWidth(line.text.take(localEnd), fontSize, fontFamily) val right = if (crossesNewline) x2 + newlineWidth else x2 if (right <= x1) return emptyList() - return listOf(UiRect(x1, 0f, right - x1, fontSize)) + return listOf(UiRect(x1, 0f, right - x1, fontHeight)) } private fun mergeSelectionRects(rects: List): List { @@ -1254,6 +1133,10 @@ internal fun handleEditableFieldKey( } if (!state.multiline && !input.shift && (input.key == GLFW.GLFW_KEY_UP || input.key == GLFW.GLFW_KEY_DOWN)) return false if (input.key == GLFW.GLFW_KEY_ESCAPE && codeInsight?.dismissSignature() == true) return true + if (input.key == GLFW.GLFW_KEY_ESCAPE && state.caretRanges.size > 1) { + state.moveCaret(state.caret, inlayAffinity = state.primaryCaret.inlayAffinity) + return true + } if (completion != null && HollowKeys.COMPLETION.matches(input)) { completion.open() return true @@ -1414,7 +1297,6 @@ private fun String.leadingSpaceCount(): Int = indexOfFirst { it != ' ' }.let { i internal const val TextFieldCaretWidth = 1f private const val EditableFieldRowBlock = 16 -private const val EditableFieldDoubleClickMillis = 350L internal const val EditableFieldLineNumberGap = 8f /** A find-in-file match on one row, in that row's own column offsets. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCompletionPopup.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCompletionPopup.kt index cb402d6a9..72201c47d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCompletionPopup.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCompletionPopup.kt @@ -223,6 +223,9 @@ internal fun EditableFieldCompletionPopup( Box(tags = listOf("ide-completion-divider"), modifier = Modifier.size(UiLength.Fill, 1.px)) CompletionPopupHint(geometry.footerHeight) } + items.getOrNull(selectedIndex)?.preview?.let { preview -> + CompletionPreviewPopup(preview, UiRect(surface.x + geometry.x, surface.y + geometry.y, geometry.width, geometry.height)) + } } @Composable diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextPresentation.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextPresentation.kt index bf0aae2cc..eece13512 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextPresentation.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextPresentation.kt @@ -12,6 +12,7 @@ internal fun rememberEditableTextPresentation( inlayHints: List, inlayHintsProvider: UiInlayHintsProvider?, inlayRevision: Long, + highlightRevision: Long = 0L, ): EditableTextPresentation { val cache = remember { EditableTextPresentationCache() } val safeCaret = normalizePresentationCaret(caret, text.length) @@ -32,7 +33,7 @@ internal fun rememberEditableTextPresentation( val visible = if (completed.matches(text, safeCaret, highlighter, inlayHintsProvider, inlayRevision)) completed else immediate - LaunchedEffect(text, analysisCaretKey, highlighter, inlayHints, inlayHintsProvider, inlayRevision) { + LaunchedEffect(text, analysisCaretKey, highlighter, highlightRevision, inlayHints, inlayHintsProvider, inlayRevision) { val reusableInlays = cache.reusableInlays(text, inlayHintsProvider, inlayRevision) val analyzed = withContext(Dispatchers.Default) { val analysis = when { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt index 9f7d6cb42..65f7d9769 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditorGutter.kt @@ -1,8 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui.widgets -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.scripting.ide.CodeFold @@ -24,11 +22,18 @@ data class UiGutterMarker( val tooltip: String, val tags: List = emptyList(), val onClick: (() -> Unit)? = null, + val tint: UiColor? = null, ) -internal fun editableFieldActionGutterWidth(folds: List, markers: List, lines: List, fontSize: Float): Float { - val slots = markers.groupingBy { lines.lineIndex(it.offset) }.eachCount().values.maxOrNull() ?: 0 - return (slots + if (folds.isEmpty()) 0 else 1) * gutterSlotSize(fontSize) +/** Keep a marker column and a fold column available even between asynchronous analysis results. */ +internal class TextFieldGutterCapacity { + private var markerColumns by mutableIntStateOf(1) + + fun width(markers: List, lines: List, fontSize: Float): Float { + val required = markers.groupingBy { lines.lineIndex(it.offset) }.eachCount().values.maxOrNull() ?: 0 + markerColumns = maxOf(markerColumns, required) + return (markerColumns + 1) * gutterSlotSize(fontSize) + } } @Composable @@ -36,6 +41,7 @@ internal fun EditableFieldGutter( layout: EditableFieldLayout, visible: IntRange, width: Float, + height: Float, color: UiColor, state: TextFieldState, folds: List, @@ -51,25 +57,45 @@ internal fun EditableFieldGutter( id = "$fieldId-gutter", mode = UiBoxMode.STACK, tags = listOf("editable-text-field-gutter", "code-editor-gutter"), - modifier = Modifier.position(0.px, 0.px, 5f).size(width.px, layout.height.px).layer(5) + modifier = Modifier.position(0.px, 0.px, 5f).size(width.px, height.px).layer(5) .pinnedToViewport(horizontal = true, vertical = false).input(hoverable = true), ) { - Box(tags = listOf("gutter-separator"), modifier = Modifier.position((width - 1f).px, 0.px) - .size(1.px, layout.height.px).background(separatorColor).inputTransparent()) + Box( + tags = listOf("gutter-separator"), + modifier = Modifier.position((width - 1f).px, 0.px).size(1.px, height.px).background(separatorColor) + .inputTransparent() + ) for (index in visible) { if (index in layout.hiddenLines) continue key(index) { val y = layout.lineTop(index) Text((index + 1).toString(), modifier = Modifier.position(0.px, y.px).foreground(color).textWrap(false)) byLine[index].orEmpty().forEachIndexed { column, marker -> - GutterButton("$fieldId-gutter-${marker.id}", marker.icon, marker.tooltip, markerX + column * slot, y, - slot, layout.lineHeight, marker.tags, marker.onClick) + GutterButton( + "$fieldId-gutter-${marker.id}", + marker.icon, + marker.tooltip, + markerX + column * slot, + y, + slot, + layout.lineHeight, + marker.tags, + marker.tint, + marker.onClick + ) } foldLines[index]?.maxByOrNull { it.end - it.start }?.let { fold -> val closed = state.folding.isCollapsed(fold) - GutterButton("$fieldId-fold-${fold.start}", "hollowengine:textures/gui/icons/gutter/fold-${if (closed) "closed" else "open"}.svg", + GutterButton( + "$fieldId-fold-${fold.start}", + "hollowengine:textures/gui/icons/gutter/fold-${if (closed) "closed" else "open"}.svg", "hollowengine.gui.ide.gutter.${if (closed) "expand" else "collapse"}".lang, - width - slot, y, slot, layout.lineHeight, listOf("fold-marker")) { + width - slot, + y, + slot, + layout.lineHeight, + listOf("fold-marker") + ) { state.folding.toggle(fold) if (state.folding.isCollapsed(fold) && state.caret > fold.start + fold.prefixLength && state.caret < fold.end) { state.moveCaret(fold.start + fold.prefixLength) @@ -82,14 +108,19 @@ internal fun EditableFieldGutter( } @Composable -private fun GutterButton(id: String, icon: String, tooltip: String, x: Float, y: Float, - width: Float, height: Float, tags: List, action: (() -> Unit)?) { - Box(id = id, mode = UiBoxMode.STACK, tags = listOf("gutter-marker") + tags + listOfNotNull("gutter-action".takeIf { action != null }), +private fun GutterButton( + id: String, icon: String, tooltip: String, x: Float, y: Float, + width: Float, height: Float, tags: List, tint: UiColor? = null, action: (() -> Unit)?, +) { + Box( + id = id, + mode = UiBoxMode.STACK, + tags = listOf("gutter-marker") + tags + listOfNotNull("gutter-action".takeIf { action != null }), modifier = Modifier.position(x.px, y.px).size(width.px, height.px) .input(hoverable = true, clickable = action != null).tooltipOnHover(tooltip) .onClick { event -> action?.invoke(); event.consume() }) { - Image(icon, tags = listOf("gutter-marker-icon"), modifier = Modifier.size((width - 4f).px, (width - 4f).px) - .align(UiAlign.CENTER, UiAlign.CENTER)) + val size = Modifier.size((width - 4f).px, (width - 4f).px).align(UiAlign.CENTER, UiAlign.CENTER) + Image(icon, tags = listOf("gutter-marker-icon"), modifier = tint?.let { size.tint(it) } ?: size) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiMenuSearch.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiMenuSearch.kt new file mode 100644 index 000000000..5a8aba322 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiMenuSearch.kt @@ -0,0 +1,42 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +/** An item a search of a menu found, and the submenus it sits in, joined for showing beside it. */ +data class UiMenuMatch(val item: UiDropdownItem, val path: String) + +/** Finds what can be picked in a menu, through every submenu, by the words typed. */ +object UiMenuSearch { + private const val LIMIT = 60 + private const val PATH_SEPARATOR = " β€Ί " + + /** + * The items every word of [query] is found in, by label or by the submenus around them: those whose label starts + * with the query first, then those whose label has it, then the rest. + */ + fun find(items: List, query: String): List { + val words = query.trim().lowercase().split(' ').filter { it.isNotEmpty() } + if (words.isEmpty()) return emptyList() + val whole = query.trim().lowercase() + return leaves(items, emptyList()) + .filter { (item, path) -> + val text = (path + item.label).joinToString(" ").lowercase() + words.all { it in text } + } + .sortedBy { (item, _) -> + val label = item.label.lowercase() + when { + label.startsWith(whole) -> 0 + whole in label -> 1 + else -> 2 + } + } + .take(LIMIT) + .map { (item, path) -> UiMenuMatch(item, path.joinToString(PATH_SEPARATOR)) } + } + + /** Every item that does something when picked, with the labels of the submenus it is in. */ + private fun leaves(items: List, path: List): List>> = + items.filter { it.enabled && it.slider == null && it.mark == null }.flatMap { item -> + val children = item.children + if (children == null) listOf(item to path) else leaves(children, path + item.label) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModel.kt index bc5431a1b..63c06ba81 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModel.kt @@ -165,6 +165,28 @@ class ModelViewerState(model: String) : OrbitCamera { selectedAnimation = ((index % count) + count) % count } + /** Centers the model and zooms it to fill [share] of the panel; false while it has no geometry to measure. */ + fun frame(share: Float = FRAME_SHARE): Boolean { + val (low, high) = attachment.calculateBounds() ?: return false + val size = maxOf(high.x - low.x, high.y - low.y, high.z - low.z) + if (size <= 0f) return false + focus(Vec3f((low.x + high.x) / 2f, (low.y + high.y) / 2f, (low.z + high.z) / 2f)) + zoomSpring.snapTo(share / size) + return true + } + + /** Plays the clip called [name] and nothing else; false while the model has no such clip. */ + fun playOnly(name: String): Boolean { + val index = animations.indexOfFirst { it.name == name } + if (index < 0) return false + selectedAnimation = index + if (playing.singleOrNull() != index) { + playing.clear() + playing.add(index) + } + return true + } + /** Toggles playback of the current animation; render() fades its weight in/out over [AnimBlendTime]. */ fun togglePlayback() { val index = selectedAnimation @@ -475,6 +497,7 @@ class ModelViewerState(model: String) : OrbitCamera { companion object { private const val PREVIEW_FRONT_YAW = 180f + private const val FRAME_SHARE = 0.85f private const val TICKS_PER_SECOND = 20f } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModelClipPreview.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModelClipPreview.kt new file mode 100644 index 000000000..34c20e537 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiModelClipPreview.kt @@ -0,0 +1,40 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.client.ui.* + +/** The model [model] playing its clip [clip], with how far into it the clip is when it runs longer than a second. */ +data class ModelClipPreview(val model: String, val clip: String) : UiCompletionPreview { + @Composable + override fun Content() { + val viewer = remember(model) { ModelViewerState(model).apply { showGrid = false } } + val loaded by viewer.modelFlow.collectAsState() + var frame by remember { mutableStateOf(0L) } + LaunchedEffect(loaded, clip) { viewer.playOnly(clip) } + LaunchedEffect(loaded) { + var framed = false + while (true) withFrameNanos { + frame = it + if (!framed) framed = viewer.frame() + } + } + Column( + tags = listOf("model-clip-preview"), + modifier = Modifier.style("hollowengine:ui/styles/model-editor.hss").size(PREVIEW_SIZE.px, UiLength.Fit), + ) { + Model(viewer, modifier = Modifier.size(100.percent, PREVIEW_SIZE.px)) + val current = viewer.currentAnimation?.takeIf { it.name == clip } + if (current != null && current.duration > LONG_CLIP_SECONDS) { + frame + Box(tags = listOf("model-progress-track")) { + Box(tags = listOf("model-progress-fill"), modifier = Modifier.size((viewer.currentAnimationProgress * 100f).percent, 100.percent)) + } + } + } + } + + private companion object { + const val PREVIEW_SIZE = 160f + const val LONG_CLIP_SECONDS = 1f + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiSubmenuIntent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiSubmenuIntent.kt new file mode 100644 index 000000000..103352819 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiSubmenuIntent.kt @@ -0,0 +1,54 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect + +/** + * Tells whether the pointer is on its way to the open submenu. Between where it last was and the near edge of the + * submenu lies a triangle: while the pointer moves inside it the items it crosses do not take the submenu over. + */ +internal class UiSubmenuIntent { + /** Where the open submenu lies on the screen, once it has been placed. */ + var submenu: UiRect? = null + + private var lastX = Float.NaN + private var lastY = Float.NaN + + /** Remembers where the pointer is, as the tip of the next triangle. */ + fun track(x: Float, y: Float) { + lastX = x + lastY = y + } + + /** Whether the pointer, now at [x] and [y], heads for the submenu. The new point becomes the tip either way. */ + fun aims(x: Float, y: Float): Boolean { + val rect = submenu + val fromX = lastX + val fromY = lastY + track(x, y) + if (rect == null || fromX.isNaN()) return false + val edge = if (rect.x >= fromX) rect.x else rect.x + rect.width + return inside(x, y, fromX, fromY, edge, rect.y - GAP, edge, rect.y + rect.height + GAP) + } + + fun reset() { + submenu = null + lastX = Float.NaN + lastY = Float.NaN + } + + private fun inside(px: Float, py: Float, ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float): Boolean { + val first = side(px, py, ax, ay, bx, by) + val second = side(px, py, bx, by, cx, cy) + val third = side(px, py, cx, cy, ax, ay) + val negative = first < 0f || second < 0f || third < 0f + val positive = first > 0f || second > 0f || third > 0f + return !(negative && positive) + } + + private fun side(px: Float, py: Float, ax: Float, ay: Float, bx: Float, by: Float): Float = + (px - bx) * (ay - by) - (ax - bx) * (py - by) + + private companion object { + const val GAP = 8f + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTextEditor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTextEditor.kt index 2ce90e942..7a65c5903 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTextEditor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTextEditor.kt @@ -90,6 +90,8 @@ data class UiTextCompletion( */ val closeness: Int = 0, val edits: List = emptyList(), + val onAccept: (() -> Unit)? = null, + val preview: UiCompletionPreview? = null, ) fun interface UiCompletionContributor { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTooltip.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTooltip.kt index 93a536213..1209d7a5f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTooltip.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTooltip.kt @@ -66,7 +66,7 @@ fun Modifier.tooltipOnHover( return then(Modifier.input(hoverable = true) .onEnter { hovered = true } .onExit { hovered = false } - .onPlaced { bounds = it }) + .onShown { bounds = it }) } private const val TooltipDelayMillis = 450L diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTreeView.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTreeView.kt index 8484cb6a4..45406b703 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTreeView.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiTreeView.kt @@ -44,12 +44,12 @@ data class UiTreeReveal(val itemId: String, val revision: Int) * For each row, whether the guide of each of its levels runs on below it: level `L` does while a * later row sits at depth `L + 1` before anything shallower closes that subtree. */ -internal fun treeGuideContinuations(items: List>): List { - val maxDepth = items.maxOfOrNull { it.depth } ?: 0 +fun treeGuideContinuations(depths: List): List { + val maxDepth = depths.maxOrNull() ?: 0 val later = BooleanArray(maxDepth + 2) - val result = arrayOfNulls(items.size) - for (index in items.indices.reversed()) { - val depth = items[index].depth + val result = arrayOfNulls(depths.size) + for (index in depths.indices.reversed()) { + val depth = depths[index] result[index] = BooleanArray(depth) { level -> later[level + 1] } later[depth] = true for (deeper in depth + 1 until later.size) later[deeper] = false @@ -85,7 +85,7 @@ fun UiTreeView( onRevealed?.invoke() } } - val continuations = remember(items) { treeGuideContinuations(items) } + val continuations = remember(items) { treeGuideContinuations(items.map { it.depth }) } Column( tags = listOf("tree-view") + tags, modifier = modifier.focus().onKeyInput(FilterShortcutPriority) { input -> diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt index 8511c2ff8..2da0d23fe 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiViewAxes.kt @@ -45,8 +45,8 @@ interface OrbitCamera { fun focus(point: Vec3f) } -/** Turns [OrbitCamera] to [yaw] and [pitch] about whatever [OrbitPivot] says the previews circle now. */ -fun OrbitCamera.orbitTo(yaw: Float, pitch: Float) = turnTo(yaw, pitch, OrbitPivot.of(this)) +/** Turns the camera about its current view center. */ +fun OrbitCamera.orbitTo(yaw: Float, pitch: Float) = turnTo(yaw, pitch) /** * Yaw and pitch for [back] on a camera whose back direction is (sin y, sin p, cos y) scaled by cos p, with y the yaw @@ -83,7 +83,6 @@ class OrbitCameraTurn(val camera: OrbitCamera) { } when { HollowKeys.VIEW_SELECTED.matches(key, modifiers) -> camera.focus(camera.selectionCenter ?: return false) - HollowKeys.ORBIT_PIVOT.matches(key, modifiers) -> OrbitPivot.aroundSelection = !OrbitPivot.aroundSelection HollowKeys.ORBIT_LEFT.matches(key, modifiers) -> orbitBy(-OrbitStep, 0f) HollowKeys.ORBIT_RIGHT.matches(key, modifiers) -> orbitBy(OrbitStep, 0f) HollowKeys.ORBIT_UP.matches(key, modifiers) -> orbitBy(0f, OrbitStep) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt index 2096da4d9..3fa32aeb1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonClassLoader.kt @@ -35,6 +35,8 @@ internal class HollowAddonClassLoader( "kotlin.sequences.", "kotlin.script.experimental.api.", "kotlin.script.experimental.host.", + // Compiled metadata and JVM modules cross the compiler addon's boundary into the runtime evaluator. + "kotlin.script.experimental.jvm.", "kotlin.script.experimental.util.", "kotlin.text.", "kotlin.time.", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt new file mode 100644 index 000000000..48becbffb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonLoadedEvent.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import java.io.File + +/** + * The addon [id] has loaded: its [classes] are on [classLoader] and its listeners are subscribed. Lets other addons + * find declarations of their own in it, the way `@SubscribeEvent` methods are found. [scope] ends when it unloads. + * + * @param packagePrefix the package an addon embedded in a mod keeps its classes in, or null for a whole jar. + */ +class HollowAddonLoadedEvent( + val id: String, + val classes: File, + val classLoader: ClassLoader, + val scope: CoroutineScope, + val packagePrefix: String?, +) : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt index 0fa1d6a2f..76ababa46 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonManager.kt @@ -1,6 +1,7 @@ package ru.hollowhorizon.hollowengine.common.addons import kotlinx.coroutines.runBlocking +import ru.hollowhorizon.hollowengine.bootstrap.runtime.AddonBootstrapContract import ru.hollowhorizon.hollowengine.common.files.DirectoryManager import java.io.File import kotlin.reflect.KClass @@ -23,8 +24,8 @@ object HollowAddonManager : AutoCloseable { get() = runtime?.let { addonRuntime -> runBlocking { addonRuntime.statuses() } }.orEmpty() /** - * Loaded addons from `hollowengine/addons` that carry assets or data, highest priority first. Addons - * in `mods` are left out: their loader already serves their resources. + * Loaded addons that carry assets or data, highest priority first. Addons in `mods` are left out: + * their loader already serves their resources. */ val resourcePacks: List get() = runtime?.resourcePackSnapshot.orEmpty() @@ -35,6 +36,8 @@ object HollowAddonManager : AutoCloseable { HollowAddonRuntime( sources = sources, cacheDirectory = cacheDirectory, + addonsDirectory = addonsDirectory, + modsDirectory = modsDirectory, ).also { runtime = it } } runCatching { runBlocking { created.start() } } @@ -47,10 +50,22 @@ object HollowAddonManager : AutoCloseable { .getOrThrow() } - /** Where addons are looked for, highest priority first. */ - internal fun defaultSources(): List = listOf( - DirectoryManager.HOLLOW_ENGINE.resolve("addons").toFile(), - File("mods").absoluteFile, + /** `hollowengine/addons`, which also keeps the list of addons switched off. */ + internal val addonsDirectory: File + get() = DirectoryManager.HOLLOW_ENGINE.resolve("addons").toFile() + + /** Addons here are mods as well, so the mod loader serves their resources. */ + internal val modsDirectory: File + get() = File("mods").absoluteFile + + /** + * Where addons are looked for, highest priority first. A development run puts the addons the + * Gradle build has just made ahead of the rest, so stale copies elsewhere never win. + */ + internal fun defaultSources(): List = listOfNotNull( + AddonBootstrapContract.developmentAddonsDirectory(), + addonsDirectory, + modsDirectory, ) internal val cacheDirectory: File diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index cc571b2c3..34227645c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -22,8 +22,9 @@ import java.net.URLClassLoader internal class HollowAddonRuntime( private val sources: List, cacheDirectory: File, + private val addonsDirectory: File, + private val modsDirectory: File, ) { - private val addonsDirectory = sources.first() private val mutex = Mutex() private val runtimeJob = SupervisorJob() private val runtimeScope = CoroutineScope(Dispatchers.IO + runtimeJob + CoroutineName("HollowEngine addons")) @@ -501,6 +502,9 @@ internal class HollowAddonRuntime( HollowAddonEventRegistrar.register( candidate.classesFile, classLoader, descriptor.id, entrypoint, addonScope, embeddedPackage ) + HollowAddonLoadedEvent.post( + HollowAddonLoadedEvent(descriptor.id, candidate.classesFile, classLoader, addonScope, embeddedPackage) + ) } registerScripts(candidate, classLoader, libraries) loadedAddons[descriptor.id] = LoadedHollowAddon( @@ -529,9 +533,10 @@ internal class HollowAddonRuntime( } /** - * Publishes the addon's `scripts/` directory as a namespace of its own. The addon jar and its - * bundled libraries go on the compilation classpath and its classloader becomes the parent of the - * compiled scripts, so an addon can move part of its own logic into scripts. + * Publishes the addon as a namespace of its own, with its `scripts/` directory if it has one. The addon jar + * and its bundled libraries go on the compilation classpath and its classloader becomes the parent of the + * compiled scripts, so an addon can move part of its own logic into scripts. An addon without scripts is + * published too: projects that list it in `dependsOn` reach its classes through it. */ private fun registerScripts( candidate: HollowAddonCandidate, @@ -548,7 +553,6 @@ internal class HollowAddonRuntime( fingerprint = descriptor.version, storageKey = candidate.fingerprint, ) - if (source.list().isEmpty()) return ScriptSourceLifecycle.install() ScriptRegistry.register(source) } @@ -616,11 +620,13 @@ internal class HollowAddonRuntime( private fun refreshSnapshot() { loadedSnapshot = loadedAddons.values.map { it.candidate.descriptor } - val addonsFolder = addonsDirectory.canonicalFile + val modsFolder = modsDirectory.canonicalFile resourcePackSnapshot = loadedAddons.values.toList().asReversed() .map(LoadedHollowAddon::candidate) .filter { candidate -> - (candidate.hasAssets || candidate.hasData) && candidate.sourceFile.parentFile?.canonicalFile == addonsFolder + // The mod loader already serves mods and the addons embedded in them. + (candidate.hasAssets || candidate.hasData) && candidate.embeddedModId == null && + candidate.sourceFile.parentFile?.canonicalFile != modsFolder } .map { candidate -> HollowAddonResourcePack( @@ -641,7 +647,11 @@ internal class HollowAddonRuntime( } finally { refreshEnabledSnapshot() mutex.unlock() - if (loadedSnapshot !== loadedBefore) reloadRunningServers() + if (loadedSnapshot !== loadedBefore) { + runCatching { HollowAddonsChangedEvent.post(HollowAddonsChangedEvent()) } + .onFailure { HollowEngine.LOGGER.error("Failed to apply the changed set of addons", it) } + reloadRunningServers() + } if (started && assetsOf(packsBefore) != assetsOf(resourcePackSnapshot)) reloadClientResources() } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt new file mode 100644 index 000000000..ebf61d4da --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonsChangedEvent.kt @@ -0,0 +1,13 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler + +/** + * The set of loaded addons has changed. Fires once after a batch of loads and unloads, such as the + * startup or an addon being enabled, so whatever was built from contributions of several addons can be + * rebuilt once they are all in place. + */ +class HollowAddonsChangedEvent : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/attachments/editor/EditorMetadata.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/attachments/editor/EditorMetadata.kt index 63e5b564e..6f67a12bb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/attachments/editor/EditorMetadata.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/attachments/editor/EditorMetadata.kt @@ -9,27 +9,31 @@ import kotlinx.serialization.SerialInfo * What the component editor knows about a component beyond its serial form. */ @SerialInfo -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD, AnnotationTarget.FUNCTION) annotation class EditorName(val name: String) /** The line under a field, or under a component's header. */ @SerialInfo -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) annotation class EditorDescription(val description: String) -/** A `namespace:textures/...` icon shown beside the component or field. */ +/** A `namespace:textures/...` icon shown beside the component, field or story action. */ @SerialInfo -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) annotation class EditorIcon(val icon: String) -/** Groups a component under a heading in the "add component" list. */ +/** + * Groups a component under a heading in the "add component" list, or a story action in the "add command" one. + * + * @param color the `#RRGGBB` the things of the category are told apart by; left empty, the category is given one. + */ @SerialInfo -@Target(AnnotationTarget.CLASS) -annotation class EditorCategory(val category: String) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class EditorCategory(val category: String, val color: String = "") -/** Kept in the data, never shown. For fields gameplay owns and a human should not type into. */ +/** Kept in the data, never shown. For fields gameplay owns and a human should not type into, or actions nothing offers. */ @SerialInfo -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) annotation class EditorHidden /** @@ -60,9 +64,12 @@ annotation class EditorDefault(val json: String) @Target(AnnotationTarget.PROPERTY) annotation class EditorBone -/** Edits the field with the editor registered under [id] instead of the one its type would get. */ +/** + * Edits the field with the editor registered under [id] instead of the one its type would get; on a story action, + * draws its rows with the widget registered under [id]. + */ @SerialInfo -@Target(AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER) annotation class EditorWidget(val id: String) /** The field editors the engine registers, by the id [EditorWidget] names them with. */ @@ -86,9 +93,24 @@ object EditorWidgets { annotation class EditorMultiline /** - * A string field holding a path to an asset, so the editor can offer what exists instead of asking the - * user to remember it. [extensions] is matched against the end of the path (`gltf`, `node.kts`, `png`). + * A string field, or parameter of a story action, holding a path to an asset, so the editor can offer what exists instead + * of asking the user to remember it. [extensions] is matched against the end of the path (`gltf`, `node.kts`, `png`). */ @SerialInfo -@Target(AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER) annotation class EditorAsset(vararg val extensions: String) + +/** + * A parameter of a story action naming a clip of the model of the entity the action is called on, so the editor offers + * the clips of that model and warns about one it lacks, when it can tell which model that is. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class EditorAnimation + +/** A parameter of a story action naming the model of the entity the action gives back. */ +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class EditorModel + +/** A parameter of a story action naming, by the author's id, the NPC the action gives back. */ +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class EditorNpcId diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt index 91504ba58..b27ff5786 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/HollowEngineCommands.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.common.commands import com.mojang.brigadier.arguments.BoolArgumentType import com.mojang.brigadier.arguments.FloatArgumentType -import com.mojang.brigadier.arguments.IntegerArgumentType import com.mojang.brigadier.arguments.StringArgumentType import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.mojang.brigadier.builder.RequiredArgumentBuilder @@ -34,7 +33,6 @@ import ru.hollowhorizon.hollowengine.common.attachments.components.TransformComp import ru.hollowhorizon.hollowengine.common.attachments.snapshot.EntitySnapshot import ru.hollowhorizon.hollowengine.common.commands.arguments.ScriptPathArgument import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueInput import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent import ru.hollowhorizon.hollowengine.common.files.DirectoryManager @@ -51,7 +49,6 @@ import ru.hollowhorizon.hollowengine.common.scripting.nodes.addNode import ru.hollowhorizon.hollowengine.common.scripting.nodes.removeNode import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext import ru.hollowhorizon.hollowengine.common.utils.* import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import ru.hollowhorizon.hollowengine.common.vfx.Vfx @@ -286,27 +283,6 @@ private fun CommandExtension.registerUtilityCommands() { SUCCESS } } - - registerDialogueCommands() -} - -private fun CommandExtension.registerDialogueCommands() { - "dialogue" { - "advance" { - executes { - if (DialogueInput.advance(source.playerOrException)) SUCCESS - else sendFailure("hollowengine.commands.dialogue_not_in_dialogue".mcTranslate()) - } - } - - "choose"(arg("option", IntegerArgumentType.integer(0))) { - executes { - val option = IntegerArgumentType.getInteger(this, "option") - if (DialogueInput.choose(source.playerOrException, option)) SUCCESS - else sendFailure("hollowengine.commands.dialogue_no_such_option".mcTranslate(option)) - } - } - } } private fun CommandExtension.registerScriptingCommands() { @@ -339,7 +315,7 @@ private fun CommandExtension.registerScriptingCommands() { } source.server.addNode( ScriptRegistry.display(id), - context = StateContext(nextState = StringArgumentType.getString(this, "state")) + entry = StringArgumentType.getString(this, "state") ) SUCCESS } @@ -503,8 +479,7 @@ private fun com.mojang.brigadier.context.CommandContext.atta if (!isNodeScriptPath(id.path)) { return sendFailure("hollowengine.commands.scripting_state_requires_node_script".mcTranslate(path)) } - val context = state?.let { StateContext(nextState = it) } - return if (EntityNodeRuntime.attach(entity, path, context = context)) { + return if (EntityNodeRuntime.attach(entity, path, entry = state)) { sendSuccess(true) { "Attached node '$path' to ${entity.name.string}".literal } } else { sendFailure("Failed to attach node '$path' to ${entity.name.string}".literal) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt index e0c7fe7fa..6b5d0ae19 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/HollowEngineConfig.kt @@ -46,10 +46,6 @@ object HollowEngineConfig : Config() { @PropertyName("transform_gizmo_orientation") var gizmoOrientation by property(GizmoOrientation.GLOBAL.name) - @PropertyComment("Whether the editor's previews turn around what is selected rather than around their own center") - @PropertyName("preview_orbit_selection") - var previewOrbitSelection by property(false) - @PropertyComment("Whether the timeline snaps times to frames: keys, the playhead, blocks") @PropertyName("timeline_snap") var timelineSnap by property(true) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt index a4c39be16..564c51feb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/data/DataKey.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.serializer import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat /** @@ -83,6 +84,7 @@ fun CompoundTag.write(key: DataKey, value: T) { fun DataKey.encode(value: T): Tag = NBTFormat.serialize(serializer, value) /** Deserializes [tag] with [key]'s serializer. */ -fun DataKey.decode(tag: Tag): T? = runCatching { NBTFormat.deserialize(serializer, tag) }.getOrNull() +fun DataKey.decode(tag: Tag): T? = runCatching { NBTFormat.deserialize(serializer, tag) } + .onFailure { HollowEngine.LOGGER.warn("Failed to decode $this", it) }.getOrNull() internal fun DataKey.default(): T? = defaultValue?.invoke() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueController.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueController.kt deleted file mode 100644 index f163eb0e4..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueController.kt +++ /dev/null @@ -1,822 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.selects.select -import net.minecraft.nbt.CompoundTag -import net.minecraft.server.MinecraftServer -import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* -import kotlin.time.Duration.Companion.milliseconds - -/** - * Runs one story. [start] plays from wherever the controller currently stands, the beginning, or - * the previous restored checkpoint. - */ -class DialogueController( - override val address: String, - val presentation: DialoguePresenter, - val functions: StoryFunctionRegistry, - val library: StoryLibrary, - val votePolicy: VotePolicy = VotePolicy(), - /** Locale to look for; null plays the base story. */ - val locale: String? = null, -) : DialogueSession { - - override lateinit var server: MinecraftServer - private set - - override var participants: List = emptyList() - private set - - override val onlineParticipants: List - get() = participants.filter { !it.hasDisconnected() } - - override val variables: MutableMap = LinkedHashMap() - - private val boundCharacters = LinkedHashMap() - override val characters: Map get() = boundCharacters - - private class Frame(val address: String, var pc: Int) - - private val frames = ArrayDeque() - private val programs = HashMap() - - /** Address and hash of the story that is playing, what a checkpoint records. */ - private var playingAddress: String = address - private var playingLocale: String? = null - - /** Checkpoint of the function currently running; empty between statements. */ - private var callState = CompoundTag() - - private val tracks = LinkedHashMap() - private var trackCounter = 0 - - private class TrackHandle(val name: String, val anchor: StoryAnchor, var job: Job? = null) - - /** A track that hit `@sync` hands its remaining position over through this channel. */ - private val preemptions = Channel(Channel.CONFLATED) - - private var advanceSignal: CompletableDeferred? = null - - /** An advance that arrived while nobody was waiting; consumed by the next wait. */ - private var pendingAdvance = false - private var pendingMenu: PendingMenu? = null - - private class PendingMenu(val optionCount: Int) { - val votes = LinkedHashMap() - val signal = CompletableDeferred() - - /** Set when a script decides for everyone; skips the vote entirely. */ - var forced: Int? = null - } - - var isRunning: Boolean = false - private set - - /** Checkpoint restored by [load], consumed by the next [start]. */ - private var restored: StoryCheckpoint? = null - - private val choiceHandlers = mutableListOf<(StoryChoiceEvent) -> Unit>() - private val startHandlers = LinkedHashMap Unit>>() - private val endHandlers = LinkedHashMap Unit>>() - private val dialogueStartHandlers = mutableListOf<(DialogueSession) -> Unit>() - private val dialogueEndHandlers = mutableListOf<(DialogueSession, DialogueResult) -> Unit>() - - fun onChoice(handler: (StoryChoiceEvent) -> Unit) { - choiceHandlers += handler - } - - /** Fires when a `tag=`-marked action begins. */ - fun onStart(tag: String, handler: (StoryActionEvent) -> Unit) { - startHandlers.getOrPut(tag) { mutableListOf() } += handler - } - - /** Fires when a `tag=`-marked action finishes, including when its track was cancelled. */ - fun onEnd(tag: String, handler: (StoryActionEndEvent) -> Unit) { - endHandlers.getOrPut(tag) { mutableListOf() } += handler - } - - /** Fires once per [start], before the first statement. */ - fun onDialogStart(handler: (DialogueSession) -> Unit) { - dialogueStartHandlers += handler - } - - /** Fires when the dialogue stops for any reason. */ - fun onDialogEnd(handler: (DialogueSession, DialogueResult) -> Unit) { - dialogueEndHandlers += handler - } - - /** Binds a character to the name stories use before the colon. */ - fun character(name: String, character: DialogueCharacter) { - boundCharacters[name] = character - } - - /** - * Plays the story with [players] as its participants, from the current position, and returns how - * it ended. Characters and participants are supplied fresh every time, an entity can never come - * back from a checkpoint. - */ - suspend fun start(vararg players: ServerPlayer, setup: DialogueStartScope.() -> Unit = {}): DialogueResult { - require(players.isNotEmpty()) { "A dialogue needs at least one participant" } - participants = players.toList() - server = players.first().server - return run(setup) - } - - /** - * Plays with no participants at all. Useful for a story that only drives the world (and for - * tests): nothing is shown to anyone unless the presenter says otherwise, and the - * "everyone left" ending cannot trigger. - */ - suspend fun startHeadless(setup: DialogueStartScope.() -> Unit = {}): DialogueResult { - participants = emptyList() - return run(setup) - } - - private suspend fun run(setup: DialogueStartScope.() -> Unit): DialogueResult { - check(!isRunning) { "Dialogue '$address' is already running" } - DialogueStartScope(this).setup() - - prepare() - isRunning = true - DialogueInput.register(this) - dialogueStartHandlers.forEach { it(this) } - DialogueEvent.Started.post(DialogueEvent.Started(this)) - - var result: DialogueResult = DialogueResult.Finished - try { - coroutineScope { - presentation.onDialogueStart(this@DialogueController) - restoreTracks(this) - result = runLoop(this) - cancelTracks() - } - } catch (e: CancellationException) { - // The scope died (server stopping): keep the checkpoint so the script can resume later. - result = DialogueResult.Cancelled - throw e - } catch (e: Throwable) { - result = DialogueResult.Failed(e) - } finally { - isRunning = false - DialogueInput.unregister(this) - val ending = result - if (ending is DialogueResult.Finished) clearState() - runCatching { presentation.onDialogueEnd(this, ending) } - dialogueEndHandlers.forEach { it(this, ending) } - DialogueEvent.Ended.post(DialogueEvent.Ended(this, ending)) - } - return result - } - - /** Starts the dialogue in [scope] without waiting for it, for scripts that fire and forget. */ - fun launchIn(scope: CoroutineScope, vararg players: ServerPlayer, setup: DialogueStartScope.() -> Unit = {}): Job = - scope.launch { start(*players, setup = setup) } - - /** Forgets the position and variables, so the next [start] plays from the beginning. */ - fun reset() { - check(!isRunning) { "Cannot reset a running dialogue" } - clearState() - } - - private fun clearState() { - frames.clear() - variables.clear() - callState = CompoundTag() - tracks.clear() - restored = null - } - - /** Writes the current position into [tag] under [key]. Safe to call while the dialogue runs. */ - fun save(key: String, tag: CompoundTag) { - val checkpoint = checkpoint() - if (checkpoint == null) tag.remove(key) else tag.put(key, checkpoint.save()) - } - - /** Restores a position previously written by [save]; the next [start] continues from it. */ - fun load(key: String, tag: CompoundTag) { - check(!isRunning) { "Cannot load into a running dialogue" } - restored = if (tag.contains(key)) StoryCheckpoint.load(tag.getCompound(key)) else null - } - - /** Current position, or null when nothing is in progress. */ - fun checkpoint(): StoryCheckpoint? { - if (!isRunning && frames.isEmpty()) return restored - val program = programs[playingAddress] ?: return restored - if (frames.isEmpty()) return null - return StoryCheckpoint( - address = playingAddress, - sourceHash = program.sourceHash, - locale = playingLocale, - frames = frames.map { frame -> - val frameProgram = programs[frame.address] ?: program - CheckpointFrame(frame.address, frameProgram.anchorOf(frame.pc)) - }, - variables = variables.toMap(), - callState = callState.copy(), - tracks = tracks.values.map { CheckpointTrack(it.name.takeUnless { n -> n.startsWith(ANONYMOUS_TRACK) }, it.anchor) }, - ) - } - - /** Resolves the story and decides where to continue: exactly, from a label, or from the start. */ - private fun prepare() { - val checkpoint = restored - if (checkpoint == null) { - val loaded = library.load(address, locale) - playingAddress = loaded.address - playingLocale = loaded.locale - programs[loaded.address] = loaded.program - frames.clear() - frames.addLast(Frame(loaded.address, 0)) - return - } - - val loaded = runCatching { LoadedStory(checkpoint.address, checkpoint.locale, library.loadExact(checkpoint.address)) } - .getOrElse { library.load(address, locale) } - playingAddress = loaded.address - playingLocale = loaded.locale - programs[loaded.address] = loaded.program - - variables.clear() - variables += checkpoint.variables - callState = checkpoint.callState.copy() - frames.clear() - - val exact = loaded.program.sourceHash == checkpoint.sourceHash && loaded.address == checkpoint.address - if (exact) { - val resolved = checkpoint.frames.map { frame -> - val program = programOf(frame.address) ?: return@map null - program.resolve(frame.anchor)?.let { Frame(frame.address, it) } - } - if (resolved.all { it != null }) { - resolved.forEach { frames.addLast(it!!) } - checkpoint.tracks.forEach { track -> - loaded.program.resolve(track.anchor)?.let { pc -> - pendingTrackRestarts += track.name to pc - } - } - return - } - } - - callState = CompoundTag() - val innermost = checkpoint.frames.last() - val labelStart = loaded.program.resolveLabelStart(innermost.anchor) - frames.addLast(Frame(loaded.address, labelStart ?: 0)) - } - - private val pendingTrackRestarts = mutableListOf>() - - private fun restoreTracks(scope: CoroutineScope) { - val restarts = pendingTrackRestarts.toList() - pendingTrackRestarts.clear() - for ((name, pc) in restarts) { - val program = programs[playingAddress] ?: continue - val instruction = program.instructions.getOrNull(pc) as? StoryInstruction.AsyncStart ?: continue - spawnTrack(scope, playingAddress, name, instruction) - } - } - - private fun programOf(address: String): StoryProgram? = - programs[address] ?: runCatching { library.loadExact(address) }.getOrNull()?.also { programs[address] = it } - - private suspend fun runLoop(scope: CoroutineScope): DialogueResult { - while (true) { - takePreemption()?.let { pc -> - frames.clear() - frames.addLast(Frame(playingAddress, pc)) - } - if (frames.isEmpty()) return DialogueResult.Finished - if (participants.isNotEmpty() && onlineParticipants.isEmpty()) return DialogueResult.AllPlayersLeft - - val frame = frames.last() - val program = programOf(frame.address) ?: return DialogueResult.Finished - val instruction = program.instructions.getOrNull(frame.pc) - if (instruction == null) { - frames.removeLast() - continue - } - val outcome = execute(scope, frame, instruction) - if (outcome != null) return outcome - } - } - - /** Executes one instruction; a non-null result ends the dialogue. */ - private suspend fun execute( - scope: CoroutineScope, - frame: Frame, - instruction: StoryInstruction, - ): DialogueResult? { - when (instruction) { - is StoryInstruction.Say -> { - val speaker = instruction.speakerExpr?.let { characterOf(evaluate(it)) } - ?: instruction.speaker?.let { resolveCharacter(it) } - if (!sayLine(speaker, instruction.text)) return null - frame.pc++ - } - - is StoryInstruction.Menu -> return runMenu(frame, instruction) - - is StoryInstruction.Set -> { - variables[instruction.variable] = evaluate(instruction.value) - frame.pc++ - } - - is StoryInstruction.Goto -> frame.pc = instruction.target - - is StoryInstruction.GotoIfFalse -> { - frame.pc = if (evaluate(instruction.condition).isTruthy()) frame.pc + 1 else instruction.target - } - - is StoryInstruction.Jump -> { - val destination = resolveTarget(instruction.target, frame.address) - frames.clear() - frames.addLast(destination) - playingAddress = destination.address - } - - is StoryInstruction.Call -> { - frame.pc++ - frames.addLast(resolveTarget(instruction.target, frame.address)) - } - - is StoryInstruction.Return -> frames.removeLast() - - is StoryInstruction.Invoke -> { - if (!invoke(instruction.call)) return null // preempted - frame.pc++ - } - - is StoryInstruction.Command -> { - runCommand(render(instruction.text)) - frame.pc++ - } - - is StoryInstruction.AsyncStart -> { - spawnTrack(scope, frame.address, instruction.trackName, instruction) - frame.pc = instruction.bodyEnd - } - - is StoryInstruction.Await -> { - val jobs = if (instruction.trackNames.isEmpty()) tracks.values.mapNotNull { it.job } - else instruction.trackNames.mapNotNull { tracks[it]?.job } - if (!preemptible { jobs.joinAll() }) return null - frame.pc++ - } - - is StoryInstruction.Cancel -> { - cancelTrack(instruction.trackName) - frame.pc++ - } - - is StoryInstruction.Sync -> { - // Reaching `@sync` on the main flow means the track already became the main flow. - frame.pc++ - } - } - return null - } - - /** Returns false when a track preempted the line. */ - private suspend fun sayLine(speaker: DialogueCharacter?, template: TextTemplate): Boolean { - if (!preemptible { presentation.beginLine(this, speaker) }) return false - val buffer = StringBuilder() - - suspend fun flush(): Boolean { - if (buffer.isEmpty()) return true - val text = buffer.toString() - buffer.clear() - return preemptible { presentation.appendText(this, text) } - } - - for (part in template.parts) { - when (part) { - is TextPart.Literal -> buffer.append(part.text) - is TextPart.Interpolation -> buffer.append(evaluate(part.expr).display()) - is TextPart.InlineCall -> { - if (!flush()) return false - if (!invokeCall(part.call.function, part.call.args.toEvaluated(), null, emptyMap())) return false - } - - is TextPart.WaitInput -> { - if (!flush()) return false - if (!preemptible { presentation.waitForInput(this) }) return false - } - } - } - if (!flush()) return false - return preemptible { presentation.endLine(this) } - } - - private suspend fun runMenu(frame: Frame, menu: StoryInstruction.Menu): DialogueResult? { - val available = menu.options.filter { option -> - option.condition?.let { evaluate(it).isTruthy() } ?: true - } - if (available.isEmpty()) { - frame.pc = menu.exit - return null - } - - val presented = available.mapIndexed { index, option -> - PresentedChoice( - index = index, - id = option.id, - text = render(option.text), - metadata = option.args.associate { arg -> (arg.name ?: "") to evaluate(arg.expr) }, - ) - } - - val menuState = PendingMenu(presented.size) - pendingMenu = menuState - var winner = -1 - try { - if (!preemptible { presentation.showChoices(this, presented) }) return null - val decided = preemptible { - if (votePolicy.timeoutMillis != null) withTimeoutOrNull(votePolicy.timeoutMillis.milliseconds) { menuState.signal.await() } - else menuState.signal.await() - } - if (!decided) return null - winner = menuState.forced?.coerceIn(0, presented.size - 1) ?: decideVote(menuState.votes, presented.size) - } finally { - pendingMenu = null - runCatching { presentation.hideChoices(this, winner) } - } - - val votes = menuState.votes - val option = available[winner] - val choice = presented[winner] - choiceHandlers.forEach { - it(StoryChoiceEvent(this, winner, choice.id, choice.text, choice.metadata, votes.toMap())) - } - DialogueEvent.Chosen.post( - DialogueEvent.Chosen(this, winner, choice.id, choice.text, choice.metadata, votes.toMap()), - ) - frame.pc = option.bodyStart - return null - } - - /** Most votes wins; ties are broken at random, and no votes at all picks the first option. */ - private fun decideVote(votes: Map, optionCount: Int): Int { - if (votes.isEmpty()) return 0 - val tally = IntArray(optionCount) - votes.values.forEach { if (it in 0 until optionCount) tally[it]++ } - val best = tally.max() - val leaders = tally.indices.filter { tally[it] == best } - return if (leaders.size == 1) leaders.first() else leaders.random() - } - - private suspend fun invoke(call: StoryCall): Boolean { - val evaluated = call.args.toEvaluated() - val metadata = call.metadata.mapValues { (_, expr) -> evaluate(expr) } - return invokeCall(call.function, evaluated, call.tag, metadata) - } - - private suspend fun invokeCall( - function: String, - evaluated: EvaluatedArgs, - tag: String?, - metadata: Map, - ): Boolean { - val resolved = functions.resolve(function, evaluated.positional, evaluated.named) - ?: throw StoryRuntimeException("No overload of '@$function' accepts these arguments") - val bound = bindActors(resolved.signature, evaluated.positional, evaluated.named) - val args = StoryArguments.bind(resolved.signature, bound.positional, bound.named, metadata) - val context = CallContext(this, callState) - - tag?.let { t -> - startHandlers[t]?.forEach { it(StoryActionEvent(this, t, function, args)) } - DialogueEvent.ActionStarted.post(DialogueEvent.ActionStarted(this, t, function, args)) - } - var reason = StoryActionEnd.COMPLETED - try { - val completed = preemptible { resolved.handler.invoke(context, args) } - if (!completed) reason = StoryActionEnd.CANCELLED - return completed - } catch (e: CancellationException) { - reason = StoryActionEnd.CANCELLED - throw e - } catch (e: Throwable) { - reason = StoryActionEnd.FAILED - throw e - } finally { - if (reason == StoryActionEnd.COMPLETED) callState = CompoundTag() - tag?.let { t -> - endHandlers[t]?.forEach { it(StoryActionEndEvent(this, t, function, args, reason)) } - DialogueEvent.ActionEnded.post(DialogueEvent.ActionEnded(this, t, function, args, reason)) - } - } - } - - private fun runCommand(command: String) { - server.commands.performPrefixedCommand(commandSource(), command) - } - - private fun commandSource() = - server.createCommandSourceStack().withPermission(4).withSuppressedOutput() - - private fun spawnTrack( - scope: CoroutineScope, - address: String, - name: String?, - instruction: StoryInstruction.AsyncStart, - ) { - val trackName = name ?: "$ANONYMOUS_TRACK${trackCounter++}" - tracks[trackName]?.job?.cancel() - val handle = TrackHandle(trackName, instruction.anchor) - tracks[trackName] = handle - handle.job = scope.launch { - try { - runTrack(address, instruction.bodyStart, instruction.bodyEnd) - } finally { - if (tracks[trackName] === handle) tracks.remove(trackName) - } - } - } - - private suspend fun runTrack(address: String, start: Int, end: Int) { - val program = programOf(address) ?: return - var pc = start - while (pc < end) { - when (val instruction = program.instructions.getOrNull(pc)) { - null -> return - is StoryInstruction.Sync -> { - preemptions.send(pc + 1) - return - } - - is StoryInstruction.Goto -> pc = instruction.target - is StoryInstruction.GotoIfFalse -> - pc = if (evaluate(instruction.condition).isTruthy()) pc + 1 else instruction.target - - is StoryInstruction.Set -> { - variables[instruction.variable] = evaluate(instruction.value) - pc++ - } - - is StoryInstruction.Invoke -> { - val evaluated = instruction.call.args.toEvaluated() - val metadata = instruction.call.metadata.mapValues { (_, expr) -> evaluate(expr) } - invokeTracked(instruction.call.function, evaluated, instruction.call.tag, metadata) - pc++ - } - - is StoryInstruction.Command -> { - runCommand(render(instruction.text)) - pc++ - } - - else -> pc++ - } - } - } - - /** Same as [invokeCall] but without preemption, a track cannot preempt itself. */ - private suspend fun invokeTracked( - function: String, - evaluated: EvaluatedArgs, - tag: String?, - metadata: Map, - ) { - val resolved = functions.resolve(function, evaluated.positional, evaluated.named) - ?: throw StoryRuntimeException("No overload of '@$function' accepts these arguments") - val bound = bindActors(resolved.signature, evaluated.positional, evaluated.named) - val args = StoryArguments.bind(resolved.signature, bound.positional, bound.named, metadata) - val context = CallContext(this, CompoundTag()) - tag?.let { t -> - startHandlers[t]?.forEach { it(StoryActionEvent(this, t, function, args)) } - DialogueEvent.ActionStarted.post(DialogueEvent.ActionStarted(this, t, function, args)) - } - var reason = StoryActionEnd.COMPLETED - try { - resolved.handler.invoke(context, args) - } catch (e: CancellationException) { - reason = StoryActionEnd.CANCELLED - throw e - } catch (e: Throwable) { - reason = StoryActionEnd.FAILED - throw e - } finally { - tag?.let { t -> - endHandlers[t]?.forEach { it(StoryActionEndEvent(this, t, function, args, reason)) } - DialogueEvent.ActionEnded.post(DialogueEvent.ActionEnded(this, t, function, args, reason)) - } - } - } - - override fun cancelTrack(name: String) { - tracks.remove(name)?.job?.cancel() - } - - private fun cancelTracks() { - tracks.values.forEach { it.job?.cancel() } - tracks.clear() - } - - private fun takePreemption(): Int? = preemptions.tryReceive().getOrNull() - - /** - * Runs [block], but gives up the moment a track asks to take over, that is what makes `@sync` - * able to interrupt a line the players are still reading or a menu nobody has answered. - * Returns false when the block was preempted. - */ - private suspend fun preemptible(block: suspend () -> Unit): Boolean = coroutineScope { - val work = async { block() } - val preempted = select { - work.onAwait { false } - preemptions.onReceive { pc -> - work.cancel() - preemptions.trySend(pc) - true - } - } - !preempted - } - - override fun advance(player: ServerPlayer) { - if (player !in participants) return - advance() - } - - /** Advances on everyone's behalf, for a script that moves the dialogue on by itself. */ - fun advance() { - val waiting = advanceSignal - if (waiting == null) pendingAdvance = true else waiting.complete(Unit) - } - - override fun choose(player: ServerPlayer, index: Int) { - val menu = pendingMenu ?: return - if (player !in participants) return - if (index !in 0 until menu.optionCount) return - menu.votes[player] = index - presentation.onVote(this, menu.votes.toMap()) - if (menu.votes.keys.containsAll(onlineParticipants)) menu.signal.complete(Unit) - } - - /** - * Decides the open menu for everyone, ignoring votes. A script uses this to answer on the - * players' behalf; tests use it to drive a dialogue with no players at all. - */ - fun forceChoice(index: Int) { - val menu = pendingMenu ?: return - menu.forced = index - menu.signal.complete(Unit) - } - - /** Options currently on screen, or empty when no menu is open. */ - val openMenuSize: Int get() = pendingMenu?.optionCount ?: 0 - - /** Suspends until any participant advances; presenters call this from [DialoguePresenter.endLine]. */ - suspend fun awaitAdvance() { - if (pendingAdvance) { - pendingAdvance = false - return - } - val signal = CompletableDeferred() - advanceSignal = signal - try { - signal.await() - } finally { - advanceSignal = null - pendingAdvance = false - } - } - - /** True while a presenter is waiting for the players to move on. */ - val isAwaitingAdvance: Boolean get() = advanceSignal != null - - private fun resolveCharacter(name: String): DialogueCharacter = - boundCharacters[name] ?: StringCharacter(name) - - /** - * The speaker a `{...}:` prefix evaluated to. An actor already carries its character, entity and - * all; anything else speaks under its displayed value, which is what lets `{player}: ...` work with - * a plain string variable. - */ - private fun characterOf(value: StoryValue): DialogueCharacter = - (value as? StoryActor)?.character ?: resolveCharacter(value.display()) - - /** - * A character an argument names: one the script bound, a participant by their name, or `player` - * for the first participant, the spelling a single-player story naturally reaches for. - */ - fun resolveActor(name: String): StoryActor? { - boundCharacters[name]?.let { return StoryActor(name, it) } - val player = when { - name.equals(PLAYER_ACTOR, ignoreCase = true) -> participants.firstOrNull() - else -> participants.firstOrNull { it.name.string == name || it.gameProfile.name == name } - } ?: return null - return StoryActor(name, DialogueCharacter.of(player)) - } - - /** - * Turns the bare names an actor parameter was written with into actors. Anything the dialogue does - * not know about is left as it was, so the function itself reports the failure with its own words. - */ - private fun bindActors( - signature: StoryFunctionSignature?, - positional: List, - named: Map, - ): EvaluatedArgs { - if (signature == null) return EvaluatedArgs(positional, named) - - fun coerce(param: StoryParam?, value: StoryValue): StoryValue { - if (param?.type != StoryType.ACTOR) return value - val name = (value as? StoryString)?.value ?: return value - return resolveActor(name) ?: value - } - - val boundPositional = positional.mapIndexed { index, value -> coerce(signature.params.getOrNull(index), value) } - val boundNamed = named.mapValues { (name, value) -> - coerce(signature.params.firstOrNull { it.name == name }, value) - } - return EvaluatedArgs(boundPositional, boundNamed) - } - - private fun resolveTarget(target: StoryTarget, currentAddress: String): Frame { - val destinationAddress = target.address ?: currentAddress - val program = programOf(destinationAddress) - ?: throw StoryRuntimeException("Story '$destinationAddress' not found") - programs[destinationAddress] = program - val pc = when (val label = target.label) { - null -> 0 - else -> program.labels[label] - ?: throw StoryRuntimeException("Label '#$label' not found in '$destinationAddress'") - } - return Frame(destinationAddress, pc) - } - - internal fun evaluate(expr: StoryExpression): StoryValue = try { - expr.evaluate(::lookup) - } catch (e: StoryEvalException) { - throw StoryRuntimeException("${e.message} (line ${e.span.line + 1})", e) - } - - private fun lookup(name: String): StoryValue? = variables[name] ?: resolveActor(name) - - private fun render(template: TextTemplate): String = buildString { - for (part in template.parts) { - when (part) { - is TextPart.Literal -> append(part.text) - is TextPart.Interpolation -> append(evaluate(part.expr).display()) - is TextPart.InlineCall, is TextPart.WaitInput -> Unit - } - } - } - - private class EvaluatedArgs(val positional: List, val named: Map) - - private fun List.toEvaluated(): EvaluatedArgs { - val positional = mutableListOf() - val named = LinkedHashMap() - for (arg in this) { - val value = evaluate(arg.expr) - if (arg.name == null) positional += value else named[arg.name] = value - } - return EvaluatedArgs(positional, named) - } - - private class CallContext( - private val controller: DialogueController, - override val state: CompoundTag, - ) : StoryCallContext { - override val session: DialogueSession get() = controller - override val server: MinecraftServer get() = controller.server - override val players: List get() = controller.onlineParticipants - - override fun checkpoint() { - controller.checkpointRequests.forEach { it(controller) } - } - } - - /** Callbacks that persist the session on demand, e.g. from a long-running function. */ - private val checkpointRequests = mutableListOf<(DialogueController) -> Unit>() - - /** Registers what to do when a story function asks for an immediate save. */ - fun onCheckpointRequest(handler: (DialogueController) -> Unit) { - checkpointRequests += handler - } - - companion object { - private const val ANONYMOUS_TRACK = "track$" - - /** Reserved actor name for the first participant. */ - const val PLAYER_ACTOR = "player" - - /** Property lookups fail outside any one expression, so they report without a position. */ - } -} - -/** Configuration applied at [DialogueController.start] time: variables, characters, the actor. */ -class DialogueStartScope(private val controller: DialogueController) { - fun variables(block: MutableMap.() -> Unit) = controller.variables.block() - - fun put(name: String, value: String) = controller.variables.put(name, StoryString(value)) - fun put(name: String, value: Number) = controller.variables.put(name, StoryNumber(value.toFloat())) - fun put(name: String, value: Boolean) = controller.variables.put(name, StoryBool(value)) - - /** Binds a speaker name to a character. */ - fun character(name: String, character: DialogueCharacter) = controller.character(name, character) -} - -class StoryRuntimeException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueDsl.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueDsl.kt deleted file mode 100644 index c14ccc1a3..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueDsl.kt +++ /dev/null @@ -1,81 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry - -/** - * Builds a [DialogueController]: - * - * ```kotlin - * val controller = DialogueController("project:stories/example.story") { - * functions.function("play-video", ["name", "volume"]) { name, volume -> … } - * } - * ``` - * - * The registry starts as a child of [StoryEngine.functions], so a dialogue sees every built-in - * function plus whatever it adds itself, without those additions leaking into other dialogues. - */ -class DialogueControllerBuilder(private val address: String) { - var presentation: DialoguePresenter = UiDialoguePresentation() - - /** Functions this dialogue may call, on top of the engine-wide ones. */ - val functions: StoryFunctionRegistry = StoryFunctionRegistry(StoryEngine.functions) - - var library: StoryLibrary = StoryEngine.library - - var votePolicy: VotePolicy = VotePolicy() - - /** Locale to look for, e.g. `ru_ru`; null plays the base story. */ - var locale: String? = null - - internal fun build() = DialogueController(address, presentation, functions, library, votePolicy, locale) -} - -fun DialogueController(address: String, block: DialogueControllerBuilder.() -> Unit = {}): DialogueController = - DialogueControllerBuilder(address).apply(block).build() - -/** - * Engine-wide dialogue state: the function registry every dialogue inherits and the library that - * loads and caches stories. Addons register their functions here. - */ -object StoryEngine { - val functions = StoryFunctionRegistry() - - val library = StoryLibrary { functions } - - init { - prepareReload() - postRegistrationEvent() - // Stories live alongside scripts, so every script namespace is a story source too. - library.addProvider(ScriptRegistryStorySource) - ScriptRegistry.addListener(ScriptRegistryStorySource) - } - - /** Registers a source of story text (a directory, an addon jar, an in-memory map). */ - fun addSource(provider: StorySourceProvider) = library.addProvider(provider) - - /** - * Drops every compiled story and lets addons and reload scripts register their functions again. - * The next load re-reads from disk. - */ - fun reload() { - prepareReload() - completeReload() - } - - /** Reinstalls engine-owned functions before reload scripts replace their event handlers. */ - internal fun prepareReload() { - StoryBuiltinFunctions.install(functions) - StoryNpcFunctions.install(functions) - StoryEffectFunctions.install(functions) - } - - /** Publishes the rebuilt registry after reload scripts have installed their new handlers. */ - internal fun completeReload() { - postRegistrationEvent() - library.invalidate() - } - - private fun postRegistrationEvent() { - RegisterStoryFunctionsEvent.post(RegisterStoryFunctionsEvent(functions)) - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueEvents.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueEvents.kt deleted file mode 100644 index a12e4a820..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueEvents.kt +++ /dev/null @@ -1,112 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.events.Event -import ru.hollowhorizon.hollowengine.common.events.ServerEvent -import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler - -/** - * Dialogue events on the engine's event bus, so an addon can react to any dialogue without holding - * its controller. The per-controller handlers (`controller.onChoice { }` and friends) are still the - * way a script reacts to *its own* dialogue; these fire for every dialogue in the game. - */ -open class DialogueEvent(val session: DialogueSession) : Event { - /** A dialogue began playing. Fires on every `start`, including a resume. */ - class Started(session: DialogueSession) : DialogueEvent(session) { - companion object : EventHandler() - } - - /** A dialogue stopped, for any reason. */ - class Ended(session: DialogueSession, val result: DialogueResult) : DialogueEvent(session) { - companion object : EventHandler() - } - - /** A menu was decided. */ - class Chosen( - session: DialogueSession, - val index: Int, - val id: String?, - val text: String, - val metadata: Map, - val votes: Map, - ) : DialogueEvent(session) { - /** Matches [id] when the story declared one, otherwise the text as written. */ - val key: String get() = id ?: text - - companion object : EventHandler() - } - - /** A `tag=`-marked action began. */ - class ActionStarted( - session: DialogueSession, - val tag: String, - val function: String, - val args: StoryArguments, - ) : DialogueEvent(session) { - companion object : EventHandler() - } - - /** A `tag=`-marked action finished, was cancelled or failed. */ - class ActionEnded( - session: DialogueSession, - val tag: String, - val function: String, - val args: StoryArguments, - val reason: StoryActionEnd, - ) : DialogueEvent(session) { - companion object : EventHandler() - } -} - -/** - * Fires while the engine-wide function registry is being filled, before any story is compiled. This - * is where an addon, or a `reload.kts` adds commands every dialogue should be able to call. - * - * It is posted again on every [StoryEngine.reload], so registrations must be idempotent: register the - * same overload twice and the registry rejects it. Use event's priority & [StoryFunctionRegistry.unregister] first if a - * script means to replace a built-in. - */ -class RegisterStoryFunctionsEvent(val functions: StoryFunctionRegistry) : ServerEvent { - companion object : EventHandler() -} - -/** - * A tagged action starting or finishing, as the controller's own handlers see it. - */ -class StoryActionEvent( - val session: DialogueSession, - val tag: String, - /** Name of the function that carried the tag, e.g. `wait` or `play-video`. */ - val function: String, - /** Its arguments, including named parameters no signature declared. */ - val args: StoryArguments, -) - -/** Why a tagged action finished, an overlay usually hides itself either way. */ -enum class StoryActionEnd { COMPLETED, CANCELLED, FAILED } - -class StoryActionEndEvent( - val session: DialogueSession, - val tag: String, - val function: String, - val args: StoryArguments, - val reason: StoryActionEnd, -) - -/** A menu decision, after the vote has been resolved. */ -class StoryChoiceEvent( - val session: DialogueSession, - val index: Int, - val id: String?, - /** - * The option's text as written in the story that is playing. Since a translated story is a file - * of its own, only [id] is stable across locales, give a choice an `id=` if Kotlin matches on it. - */ - val text: String, - val metadata: Map, - /** What each participant voted for; empty for a single-player dialogue that just clicked. */ - val votes: Map, -) { - /** Matches [id] when the story declared one, otherwise the text. */ - val key: String get() = id ?: text -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueInput.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueInput.kt deleted file mode 100644 index ac95f5fc7..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueInput.kt +++ /dev/null @@ -1,39 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.server.level.ServerPlayer -import java.util.concurrent.CopyOnWriteArrayList - -/** - * Routes a player's clicks to the dialogue they are in. - */ -object DialogueInput { - private val running = CopyOnWriteArrayList() - - internal fun register(controller: DialogueController) { - running.remove(controller) - running += controller - } - - internal fun unregister(controller: DialogueController) { - running.remove(controller) - } - - /** The dialogue [player] is taking part in, most recently started first. */ - fun sessionOf(player: ServerPlayer): DialogueController? = - running.lastOrNull { it.isRunning && player in it.participants } - - /** Advances the line [player] is reading. Returns false when they are not in a dialogue. */ - fun advance(player: ServerPlayer): Boolean { - val controller = sessionOf(player) ?: return false - controller.advance(player) - return true - } - - /** Votes for the menu option at [index]. Returns false when no menu is open for [player]. */ - fun choose(player: ServerPlayer, index: Int): Boolean { - val controller = sessionOf(player) ?: return false - if (controller.openMenuSize <= index || index < 0) return false - controller.choose(player, index) - return true - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialoguePresenter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialoguePresenter.kt deleted file mode 100644 index d6463dbf0..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialoguePresenter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.server.level.ServerPlayer - -/** - * How a dialogue reaches players. The engine drives it one fragment at a time, so a presenter never - * has to understand the story language: inline commands and `[-]` pauses are already split out by the - * time [appendText] is called. - * - * Implementations decide how a line is advanced, a click, a delay, a button - and how the UI looks; - * the default ships as a scripted overlay, so re-skinning needs no engine code. - */ -interface DialoguePresenter { - /** Opens the dialogue UI. Runs before the first line, and again after resume. */ - suspend fun onDialogueStart(session: DialogueSession) {} - - /** Closes the dialogue UI. Runs on every ending, including cancellation. */ - suspend fun onDialogueEnd(session: DialogueSession, result: DialogueResult) {} - - /** Starts a new line; [speaker] is null for narrator text. */ - suspend fun beginLine(session: DialogueSession, speaker: DialogueCharacter?) - - /** Appends the next fragment and returns once it has been shown (typed out, if animated). */ - suspend fun appendText(session: DialogueSession, text: String) - - /** `[-]`: pauses inside a line until the participants confirm. */ - suspend fun waitForInput(session: DialogueSession) - - /** Ends the line and returns once the participants have advanced past it. */ - suspend fun endLine(session: DialogueSession) - - /** Shows every option of a menu at once and returns immediately; votes arrive via the session. */ - suspend fun showChoices(session: DialogueSession, options: List) - - /** - * A participant voted. Called for every vote, not just the deciding one, so a UI can show what - * is already settled. Unlike the rest of the presenter this runs wherever the vote arrived, - * outside the story coroutine, and so cannot suspend. - */ - fun onVote(session: DialogueSession, votes: Map) {} - - /** - * Hides the menu. [chosen] is the winning option, or -1 when the menu ended without a decision - * (an `@sync` track took over, or the dialogue was cancelled). - */ - suspend fun hideChoices(session: DialogueSession, chosen: Int) -} - -/** One button of a menu as the presenter sees it. */ -data class PresentedChoice( - /** Index inside the menu, what [DialogueSession.choose] expects. */ - val index: Int, - val id: String?, - val text: String, - /** Named parameters written on `@choice` that the signature did not claim (icons, colors, etc.). */ - val metadata: Map, -) - -/** How a dialogue ended. */ -sealed interface DialogueResult { - /** The story ran to its end (or `@return` popped the last frame). */ - data object Finished : DialogueResult - - /** Every participant left; the checkpoint is kept so the script may resume later. */ - data object AllPlayersLeft : DialogueResult - - /** The scope was cancelled (server stopping). The checkpoint is kept. */ - data object Cancelled : DialogueResult - - /** A statement failed; [error] is the cause, and the checkpoint is kept for inspection. */ - data class Failed(val error: Throwable) : DialogueResult -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueSession.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueSession.kt deleted file mode 100644 index a208c91ef..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueSession.kt +++ /dev/null @@ -1,43 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.server.MinecraftServer -import net.minecraft.server.level.ServerPlayer - -/** - * The read-and-input side of a running dialogue, as presenters and story functions see it. - * The implementation is [DialogueController], which is the session itself, there is no separate - * session object to keep track of. - */ -interface DialogueSession { - val address: String - val server: MinecraftServer - - /** Participants fixed when the dialogue started; offline players stay in the list. */ - val participants: List - - /** Participants that are currently online, the ones a vote actually waits for. */ - val onlineParticipants: List - - /** Story variables. Writing here from Kotlin is allowed and lands in the next checkpoint. */ - val variables: MutableMap - - /** Characters bound by name. */ - val characters: Map - - /** Advances past the current line (a click, a button, whatever the presenter uses). */ - fun advance(player: ServerPlayer) - - /** Records [player]'s vote for the menu option at [index]. */ - fun choose(player: ServerPlayer, index: Int) - - /** Cancels a running async track by name. */ - fun cancelTrack(name: String) -} - -/** How a menu decides between several participants. */ -data class VotePolicy( - /** Milliseconds to wait for the remaining voters; null waits indefinitely. */ - val timeoutMillis: Long? = null, - /** Whether players who have not voted when the timeout hits count as abstaining. */ - val abstainOnTimeout: Boolean = true, -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueUiContract.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueUiContract.kt deleted file mode 100644 index 159f19c9b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/DialogueUiContract.kt +++ /dev/null @@ -1,102 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import kotlinx.serialization.Serializable -import ru.hollowhorizon.hollowengine.common.data.dataKey -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys.ChooseAction -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys.Index - -/** - * What [UiDialoguePresentation] writes into the UI session, and what the screen reads back. - * - * It is a public contract on purpose: the built-in screen is only the default, and a `.ui.kts` - * script that declares the same id (or any other id passed to the presenter) replaces it while - * still being driven by these keys. Everything the screen needs to draw a frame is in here, so a - * custom UI never has to reach into the engine. - */ -object DialogueUiKeys { - /** The line being read. */ - val Line = dataKey("line") { DialogueLineView() } - - /** The open menu; [DialogueChoicesView.options] is empty while no menu is up. */ - val Choices = dataKey("choices") { DialogueChoicesView() } - - /** Where the dialogue is as a whole; see [DialoguePhase]. */ - val Phase = dataKey("phase") { DialoguePhase.LINE } - - /** `send { putString(Action, ...) }`, what a screen sends back. */ - const val Action = "action" - - /** Index of the option a player voted for, alongside [ChooseAction]. */ - const val Index = "index" - - /** Moves past the current line. Ignored unless the dialogue is waiting for it. */ - const val AdvanceAction = "advance" - - /** Votes for the option at [Index]. */ - const val ChooseAction = "choose" -} - -/** Values of [DialogueUiKeys.Phase]. */ -object DialoguePhase { - /** A line is being typed out or waiting to be advanced. */ - const val LINE = "line" - - /** A menu is open. */ - const val CHOICES = "choices" - - /** The dialogue is over; the screen plays its exit animation and the server closes it. */ - const val CLOSING = "closing" -} - -/** - * The line on screen. The text arrives whole and the client types it out at [charDelay], which - * keeps the animation off the network while staying in step with the server: the story waits - * exactly as long as the typing takes. - */ -@Serializable -data class DialogueLineView( - /** Bumped for every new line; a change is what resets the typewriter. */ - val id: Int = 0, - /** Empty for narration, which hides the name plate. */ - val speaker: String = "", - /** - * The speaker's entity as this client knows it, or -1 when the speaker is a plain name. An - * entity cannot travel, so what travels is its id and the client draws the one it already has, - * live equipment, pose and all. See `UiEntityRef`. - */ - val speakerEntity: Int = -1, - val text: String = "", - /** Number of rendered Unicode characters in [text], excluding formatting markup. */ - val visibleLength: Int = 0, - /** Milliseconds per character; 0 shows the whole line at once. */ - val charDelay: Int = 0, - /** Bumped when a reader asked to see the rest of the line immediately. */ - val skips: Int = 0, - /** Whether the line is finished and the dialogue is waiting for a click. */ - val awaiting: Boolean = false, -) - -/** The open menu, as one participant sees it. */ -@Serializable -data class DialogueChoicesView( - val options: List = emptyList(), - /** What this player voted for, or -1. */ - val myVote: Int = -1, - /** How many participants have voted, and how many are expected; shown while waiting. */ - val votes: Int = 0, - val voters: Int = 0, - /** The winning option once the vote is settled, or -1. Drives the selection animation. */ - val decided: Int = -1, -) - -/** One button of the menu. */ -@Serializable -data class DialogueChoiceView( - val index: Int = 0, - val text: String = "", - /** - * Texture drawn to the left of the button, from the `icon=` parameter of `@choice`. Empty - * leaves the slot out entirely. - */ - val icon: String = "", -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/ScriptRegistryStorySource.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/ScriptRegistryStorySource.kt deleted file mode 100644 index b35f34b8b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/ScriptRegistryStorySource.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import ru.hollowhorizon.hollowengine.HollowEngine -import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptSourceListener - -/** - * Reads `.story` files out of the same namespaces that provide scripts, so a story is addressed - * exactly like a script: `project:stories/example.story`, or an unqualified path for the sandbox. - */ -object ScriptRegistryStorySource : StorySourceProvider, ScriptSourceListener { - override fun read(address: String): String? { - val artifacts = runCatching { ScriptRegistry.artifacts(address) } - .onFailure { HollowEngine.LOGGER.error("Failed to read story '$address'", it) } - .getOrNull() ?: return null - val file = artifacts.sourceFile?.takeIf { it.isFile } ?: return null - return runCatching { file.readText() } - .onFailure { HollowEngine.LOGGER.error("Failed to read story file $file", it) } - .getOrNull() - } - - /** A namespace appearing or disappearing can change what a story address resolves to. */ - override fun onScriptSourceChanged(namespace: String, available: Boolean) { - StoryEngine.library.invalidate() - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryArguments.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryArguments.kt deleted file mode 100644 index fc51197bf..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryArguments.kt +++ /dev/null @@ -1,145 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.world.entity.LivingEntity -import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionSignature -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryParam - -/** - * Evaluated arguments of one call, already matched against the chosen overload. Positional and named - * arguments end up in the same place: a function reads `args.string("name")` no matter how the story - * wrote it. - */ -class StoryArguments( - val signature: StoryFunctionSignature?, - private val byName: Map, - private val positional: List, - /** Named parameters the signature does not declare, passed through to event handlers. */ - val metadata: Map, -) { - operator fun get(name: String): StoryValue? = byName[name] - - operator fun get(index: Int): StoryValue? = positional.getOrNull(index) - - fun string(name: String): String = require(name).let { - (it as? StoryString)?.value ?: it.display() - } - - fun stringOrNull(name: String): String? = byName[name]?.let { (it as? StoryString)?.value ?: it.display() } - - fun number(name: String): Float = when (val value = require(name)) { - is StoryNumber -> value.value - is StoryString -> value.value.toFloatOrNull() - ?: throw IllegalArgumentException("Argument '$name' is not a number: '${value.value}'") - - else -> throw IllegalArgumentException("Argument '$name' is not a number") - } - - fun numberOrNull(name: String): Float? = if (byName.containsKey(name)) number(name) else null - - fun int(name: String): Int = number(name).toInt() - - fun bool(name: String): Boolean = when (val value = require(name)) { - is StoryBool -> value.value - else -> value.isTruthy() - } - - fun list(name: String): List = when (val value = require(name)) { - is StoryList -> value.values - else -> listOf(value) - } - - /** The character bound to an actor argument. */ - fun actor(name: String): DialogueCharacter = when (val value = require(name)) { - is StoryActor -> value.character - else -> throw IllegalArgumentException( - "Argument '$name' is not a character: no actor named '${value.display()}' is bound to this dialogue", - ) - } - - fun actorOrNull(name: String): DialogueCharacter? = (byName[name] as? StoryActor)?.character - - /** - * The entity behind an actor argument. - */ - fun entity(name: String): LivingEntity = when (val character = actor(name)) { - is EntityCharacter -> character.entity - is StringCharacter -> throw IllegalArgumentException( - "'${character.name}' is a name without an entity, so it cannot be moved or animated", - ) - } - - /** Three numbers in a row or a `[x, y, z]` list, whichever the story wrote. */ - fun vec3(name: String): Vec3 = when (val value = require(name)) { - is StoryList -> { - require(value.values.size == 3) { "Argument '$name' must hold three numbers, got ${value.values.size}" } - Vec3( - (value.values[0] as? StoryNumber)?.value?.toDouble() ?: badVector(name), - (value.values[1] as? StoryNumber)?.value?.toDouble() ?: badVector(name), - (value.values[2] as? StoryNumber)?.value?.toDouble() ?: badVector(name), - ) - } - - else -> throw IllegalArgumentException("Argument '$name' must be a position like [10, 64, 20]") - } - - fun vec3OrNull(name: String): Vec3? = if (byName.containsKey(name)) vec3(name) else null - - private fun badVector(name: String): Nothing = - throw IllegalArgumentException("Argument '$name' must hold three numbers") - - /** Milliseconds of a duration argument (`2sec` and `2000` are the same value by then). */ - fun millis(name: String): Long = number(name).toLong() - - private fun require(name: String): StoryValue = - byName[name] ?: throw IllegalArgumentException("Missing argument '$name'") - - companion object { - /** - * Binds evaluated values to [signature]: positional values fill parameters in order, named - * ones by name; anything the signature does not declare becomes metadata. - */ - fun bind( - signature: StoryFunctionSignature?, - positional: List, - named: Map, - metadata: Map, - ): StoryArguments { - val byName = LinkedHashMap() - val extra = LinkedHashMap(metadata) - if (signature != null) { - signature.params.forEach { param -> param.default?.let { byName[param.name] = it } } - positional.forEachIndexed { index, value -> - signature.params.getOrNull(index)?.let { byName[it.name] = value } - } - for ((name, value) in named) { - if (signature.params.any { it.name == name }) byName[name] = value else extra[name] = value - } - } else { - byName += named - extra += named - } - return StoryArguments(signature, byName, positional, extra) - } - - fun fits( - signature: StoryFunctionSignature, - positional: List, - named: Map, - ): Boolean { - if (positional.size > signature.params.size) return false - val bound = HashSet() - positional.forEachIndexed { index, value -> - val param: StoryParam = signature.params[index] - if (!param.type.accepts(value)) return false - bound += param.name - } - for ((name, value) in named) { - val param = signature.params.firstOrNull { it.name == name } ?: continue - if (name in bound || !param.type.accepts(value)) return false - bound += name - } - return signature.params.none { !it.optional && it.name !in bound } - } - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryBuiltinFunctions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryBuiltinFunctions.kt deleted file mode 100644 index bb1cee215..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryBuiltinFunctions.kt +++ /dev/null @@ -1,58 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import kotlinx.coroutines.delay -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.playSound -import kotlin.time.Duration.Companion.milliseconds - -/** - * The functions every story can call without an addon registering anything. Deliberately few: the - * language ships the control flow, and the engine ships only what a story cannot express itself. - */ -internal object StoryBuiltinFunctions { - /** Names this object owns. Dropped before re-registering, so installing twice is harmless. */ - private val OWNED = listOf("wait", "play-sound") - - fun install(registry: StoryFunctionRegistry) { - OWNED.forEach(registry::unregister) - registry.waitFunction() - registry.soundFunctions() - } - - /** - * `@wait 2s`, a pause that survives a restart. The deadline goes into the call's own checkpoint, - * so a server that comes back after three seconds does not make the players wait again. - */ - private fun StoryFunctionRegistry.waitFunction() { - add("wait", number("time")) { args -> - val deadline = if (state.contains(DEADLINE_KEY)) { - state.getLong(DEADLINE_KEY) - } else { - (System.currentTimeMillis() + args.millis("time")).also { state.putLong(DEADLINE_KEY, it) } - } - while (true) { - val remaining = deadline - System.currentTimeMillis() - if (remaining <= 0L) break - delay(minOf(remaining, MAX_SLEEP_MILLIS).milliseconds) - } - state.remove(DEADLINE_KEY) - } - } - - /** `@play-sound minecraft:block.note_block.bell 1.0`, heard by every participant. */ - private fun StoryFunctionRegistry.soundFunctions() { - add("play-sound", string("name")) { args -> - players.forEach { it.playSound(args.string("name")) } - } - add("play-sound", string("name"), number("volume")) { args -> - players.forEach { it.playSound(args.string("name"), args.number("volume")) } - } - add("play-sound", string("name"), number("volume"), number("pitch")) { args -> - players.forEach { it.playSound(args.string("name"), args.number("volume"), args.number("pitch")) } - } - } - - private const val DEADLINE_KEY = "deadline" - private const val MAX_SLEEP_MILLIS = 1_000L -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryCheckpoint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryCheckpoint.kt deleted file mode 100644 index c8f5032ca..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryCheckpoint.kt +++ /dev/null @@ -1,166 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.nbt.CompoundTag -import net.minecraft.nbt.ListTag -import net.minecraft.nbt.Tag -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryAnchor - -/** - * Everything needed to continue a dialogue after a restart. Written after every statement, so the - * worst case a player can lose is the statement that was running. - * - * The position is stored as [StoryAnchor]s, label plus offset inside its section, rather than raw - * instruction indices, which is what lets a resumed dialogue survive an edited story: an exact - * [sourceHash] match resumes precisely, a surviving label rolls back to that label, and neither means - * the story restarts. - */ -data class StoryCheckpoint( - val address: String, - val sourceHash: String, - val locale: String?, - /** Call stack, outermost first; the last frame is where execution is. */ - val frames: List, - val variables: Map, - /** Checkpoint written by the function that was running, if any. */ - val callState: CompoundTag = CompoundTag(), - /** Async tracks that had not finished; each restarts from its `@async` on resume. */ - val tracks: List = emptyList(), -) { - fun save(): CompoundTag = CompoundTag().apply { - putString(KEY_ADDRESS, address) - putString(KEY_HASH, sourceHash) - locale?.let { putString(KEY_LOCALE, it) } - put(KEY_FRAMES, ListTag().apply { frames.forEach { add(it.save()) } }) - put(KEY_VARIABLES, saveVariables(variables)) - if (!callState.isEmpty) put(KEY_CALL_STATE, callState) - if (tracks.isNotEmpty()) put(KEY_TRACKS, ListTag().apply { tracks.forEach { add(it.save()) } }) - } - - companion object { - private const val KEY_ADDRESS = "address" - private const val KEY_HASH = "hash" - private const val KEY_LOCALE = "locale" - private const val KEY_FRAMES = "frames" - private const val KEY_VARIABLES = "variables" - private const val KEY_CALL_STATE = "call_state" - private const val KEY_TRACKS = "tracks" - - fun load(tag: CompoundTag): StoryCheckpoint? { - if (!tag.contains(KEY_ADDRESS) || !tag.contains(KEY_FRAMES)) return null - val frames = tag.getList(KEY_FRAMES, Tag.TAG_COMPOUND.toInt()) - .map { CheckpointFrame.load(it as CompoundTag) } - if (frames.isEmpty()) return null - val tracks = tag.getList(KEY_TRACKS, Tag.TAG_COMPOUND.toInt()) - .map { CheckpointTrack.load(it as CompoundTag) } - return StoryCheckpoint( - address = tag.getString(KEY_ADDRESS), - sourceHash = tag.getString(KEY_HASH), - locale = tag.getString(KEY_LOCALE).takeIf { it.isNotEmpty() }, - frames = frames, - variables = loadVariables(tag.getCompound(KEY_VARIABLES)), - callState = tag.getCompound(KEY_CALL_STATE), - tracks = tracks, - ) - } - - private const val TYPE_STRING = "s" - private const val TYPE_NUMBER = "n" - private const val TYPE_BOOL = "b" - private const val TYPE_LIST = "l" - - /** - * Variables are written with an explicit type tag. NBT would happily coerce a `1` written as - * a number into an int on the way back, and a story that compares `money == 1` must not start - * failing because of a round trip. - */ - private fun saveVariables(variables: Map) = CompoundTag().apply { - for ((name, value) in variables) { - put(name, saveValue(value) ?: continue) - } - } - - private fun saveValue(value: StoryValue): CompoundTag? { - val entry = CompoundTag() - when (value) { - is StoryString -> { - entry.putString("t", TYPE_STRING) - entry.putString("v", value.value) - } - - is StoryNumber -> { - entry.putString("t", TYPE_NUMBER) - entry.putFloat("v", value.value) - } - - is StoryBool -> { - entry.putString("t", TYPE_BOOL) - entry.putBoolean("v", value.value) - } - - is StoryList -> { - entry.putString("t", TYPE_LIST) - entry.put("v", ListTag().apply { value.values.forEach { item -> saveValue(item)?.let(::add) } }) - } - - is StoryActor -> return null - } - return entry - } - - private fun loadVariables(tag: CompoundTag): Map = - tag.allKeys.mapNotNull { name -> loadValue(tag.getCompound(name))?.let { name to it } }.toMap() - - private fun loadValue(entry: CompoundTag): StoryValue? = when (entry.getString("t")) { - TYPE_STRING -> StoryString(entry.getString("v")) - TYPE_NUMBER -> StoryNumber(entry.getFloat("v")) - TYPE_BOOL -> StoryBool(entry.getBoolean("v")) - TYPE_LIST -> StoryList( - entry.getList("v", Tag.TAG_COMPOUND.toInt()).mapNotNull { loadValue(it as CompoundTag) }, - ) - - else -> null - } - } -} - -/** One entry of the call stack: which story it is in, and where. */ -data class CheckpointFrame(val address: String, val anchor: StoryAnchor) { - fun save(): CompoundTag = CompoundTag().apply { - putString("address", address) - anchor.label?.let { putString("label", it) } - putInt("offset", anchor.offset) - putInt("line", anchor.line) - } - - companion object { - fun load(tag: CompoundTag) = CheckpointFrame( - address = tag.getString("address"), - anchor = StoryAnchor( - label = tag.getString("label").takeIf { it.isNotEmpty() }, - offset = tag.getInt("offset"), - line = tag.getInt("line"), - ), - ) - } -} - -/** An unfinished async track. It is restarted whole, so only its `@async` position is stored. */ -data class CheckpointTrack(val name: String?, val anchor: StoryAnchor) { - fun save(): CompoundTag = CompoundTag().apply { - name?.let { putString("name", it) } - anchor.label?.let { putString("label", it) } - putInt("offset", anchor.offset) - putInt("line", anchor.line) - } - - companion object { - fun load(tag: CompoundTag) = CheckpointTrack( - name = tag.getString("name").takeIf { it.isNotEmpty() }, - anchor = StoryAnchor( - label = tag.getString("label").takeIf { it.isNotEmpty() }, - offset = tag.getInt("offset"), - line = tag.getInt("line"), - ), - ) - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectFunctions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectFunctions.kt deleted file mode 100644 index e92d68879..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryEffectFunctions.kt +++ /dev/null @@ -1,223 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import kotlinx.coroutines.delay -import net.minecraft.resources.ResourceLocation -import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.dialogue.lang.any -import ru.hollowhorizon.hollowengine.common.dialogue.lang.list -import ru.hollowhorizon.hollowengine.common.dialogue.lang.number -import ru.hollowhorizon.hollowengine.common.dialogue.lang.string -import ru.hollowhorizon.hollowengine.common.events.entity.LivingEntityDeathEvent -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.playCutscene -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.stopCutscene -import ru.hollowhorizon.hollowengine.common.ui.hud.EngineHudLayers -import ru.hollowhorizon.hollowengine.common.ui.hud.VanillaHudLayers -import ru.hollowhorizon.hollowengine.common.ui.net.hiddenHudLayers -import ru.hollowhorizon.hollowengine.common.ui.net.hideHudLayers -import ru.hollowhorizon.hollowengine.common.ui.net.showAllHudLayers -import java.util.* -import kotlin.time.Duration.Companion.milliseconds - -/** - * Screen effects a story can ask for: fades, the HUD, the camera and cutscenes. - * - * All of it is client state that a dialogue borrows, so all of it is tracked per session and given - * back when the dialogue stops, for any reason, including a crash or the server going down mid-scene. - * A player must never be left staring at a black screen with no HUD and a camera that is not theirs. - */ -internal object StoryEffectFunctions { - private val OWNED = listOf("fade-in", "fade-out", "hide-hud", "show-hud", "camera", "release-camera", "cutscene") - - /** What each running dialogue has taken over, so it can be handed back. */ - private val borrowed = Collections.synchronizedMap(WeakHashMap()) - - private class BorrowedVisuals { - var fade = false - var hud = false - var camera = false - var cutscene = false - - /** What the player had hidden before the dialogue touched the HUD. */ - val hudBefore = HashMap>() - } - - fun install(registry: StoryFunctionRegistry) { - OWNED.forEach(registry::unregister) - registry.fades() - registry.hud() - registry.camera() - registry.cutscenes() - listenForEndings() - } - - private fun StoryFunctionRegistry.fades() { - add( - "fade-in", - number("time", default = DEFAULT_FADE_MILLIS.toFloat()), - string("color", default = "#000000"), - ) { args -> - val duration = args.millis("time") - borrow(session).fade = true - session.eachPlayer { StoryFadePacket(1f, duration, args.color()).send(it) } - delay(duration.milliseconds) - } - - add( - "fade-out", - number("time", default = DEFAULT_FADE_MILLIS.toFloat()), - string("color", default = "#000000"), - ) { args -> - val duration = args.millis("time") - session.eachPlayer { StoryFadePacket(0f, duration, args.color()).send(it) } - delay(duration.milliseconds) - borrow(session).fade = false - } - } - - /** - * `@hide-hud` hides everything, `@hide-hud except=chat` keeps what is listed, and - * `@hide-hud only=[crosshair, hotbar]` hides just those. Both parameters take a list or a - * comma-separated string, and bare vanilla names (`chat`) as well as full ids. - */ - private fun StoryFunctionRegistry.hud() { - add( - "hide-hud", - any("only", optional = true, suggestions = LAYER_NAMES), - any("except", optional = true, suggestions = LAYER_NAMES), - ) { args -> - val only = args.layers("only") - val layers = (only ?: (VanillaHudLayers.all + EngineHudLayers.all)) - args.layers("except").orEmpty() - - val state = borrow(session) - session.eachPlayer { player -> - state.hudBefore.putIfAbsent(player, player.hiddenHudLayers()) - player.hideHudLayers(*layers.toTypedArray()) - } - state.hud = true - } - - add("show-hud") { - restoreHud(session) - } - } - - /** Accepts `[chat, hotbar]` and `"chat, hotbar"` alike; null when the story left it out. */ - private fun StoryArguments.layers(name: String): Set? { - val value = this[name] ?: return null - val names = when (value) { - is StoryList -> value.values.map { it.display() } - else -> value.display().split(',') - } - return names.map { EngineHudLayers.parse(it.trim()) }.toSet() - } - - private fun StoryFunctionRegistry.camera() { - add( - "camera", - list("location"), list("rotation"), - number("time", default = DEFAULT_CAMERA_MILLIS.toFloat()), - number("fov", default = DEFAULT_FOV), - ) { args -> - val position = args.vec3("location") - val angles = args.list("rotation").map { (it as? StoryNumber)?.value ?: 0f } - require(angles.size in 2..3) { "camera rotation must be [yaw, pitch] or [yaw, pitch, roll]" } - val duration = args.millis("time") - - borrow(session).camera = true - session.eachPlayer { - StoryCameraPacket( - position.x.toFloat(), position.y.toFloat(), position.z.toFloat(), - pitch = angles[1], yaw = angles[0], roll = angles.getOrElse(2) { 0f }, - fov = args.number("fov"), - durationMillis = duration, - ).send(it) - } - delay(duration.milliseconds) - } - - add("release-camera", number("time", default = DEFAULT_CAMERA_MILLIS.toFloat())) { args -> - val duration = args.millis("time") - session.eachPlayer { StoryReleaseCameraPacket(duration).send(it) } - delay(duration.milliseconds) - borrow(session).camera = false - } - } - - private fun StoryFunctionRegistry.cutscenes() { - add("cutscene", string("name"), number("time", optional = true)) { args -> - borrow(session).cutscene = true - session.eachPlayer { it.playCutscene(args.string("name")) } - args.numberOrNull("time")?.let { delay(it.toLong().milliseconds) } - } - } - - /** - * Every ending goes through here, which is the point: a story that faded out and never faded back - * in, or that was cut off by a server stop, still gives the screen back. - */ - private fun listenForEndings() { - if (!listening.compareAndSet(false, true)) return - DialogueEvent.Ended.register { event -> restoreAll(event.session) } - LivingEntityDeathEvent.register { event -> (event.entity as? ServerPlayer)?.let(::restoreFor) } - } - - /** Hands one player's visuals back without touching the dialogue the others are still in. */ - private fun restoreFor(player: ServerPlayer) { - val states = synchronized(borrowed) { borrowed.values.toList() } - for (state in states) { - if (state.fade) StoryClearFadePacket.send(player) - if (state.camera || state.cutscene) { - StoryReleaseCameraPacket(0L).send(player) - player.stopCutscene() - } - if (state.hud && state.hudBefore.containsKey(player)) { - val before = state.hudBefore.remove(player) - if (before.isNullOrEmpty()) player.showAllHudLayers() else player.hideHudLayers(*before.toTypedArray()) - } - } - } - - private val listening = java.util.concurrent.atomic.AtomicBoolean(false) - - private fun restoreAll(session: DialogueSession) { - val state = borrowed.remove(session) ?: return - if (state.fade) session.eachPlayer { StoryClearFadePacket.send(it) } - if (state.camera || state.cutscene) session.eachPlayer { - StoryReleaseCameraPacket(0L).send(it) - it.stopCutscene() - } - if (state.hud) restoreHudFrom(state, session) - } - - private fun restoreHud(session: DialogueSession) { - val state = borrowed[session] ?: return - restoreHudFrom(state, session) - state.hud = false - } - - private fun restoreHudFrom(state: BorrowedVisuals, session: DialogueSession) { - session.eachPlayer { player -> - val before = state.hudBefore.remove(player) - if (before.isNullOrEmpty()) player.showAllHudLayers() else player.hideHudLayers(*before.toTypedArray()) - } - } - - private fun borrow(session: DialogueSession): BorrowedVisuals = - borrowed.getOrPut(session) { BorrowedVisuals() } - - private inline fun DialogueSession.eachPlayer(action: (ServerPlayer) -> Unit) = - onlineParticipants.forEach(action) - - /** `#RRGGBB`, or black when the story did not say. */ - private fun StoryArguments.color(): Int = - stringOrNull("color")?.removePrefix("#")?.toIntOrNull(16) ?: 0x000000 - - /** Offered by the editor for `only=`/`except=`; the bare names, which [VanillaHudLayers.parse] takes. */ - private val LAYER_NAMES = (VanillaHudLayers.all + EngineHudLayers.all) - .map { if (it.namespace == "minecraft") it.path else it.toString() } - .sorted() - - private const val DEFAULT_FADE_MILLIS = 500L - private const val DEFAULT_CAMERA_MILLIS = 1_000L - private const val DEFAULT_FOV = 70f -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryFunctions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryFunctions.kt deleted file mode 100644 index 9bc26125d..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryFunctions.kt +++ /dev/null @@ -1,185 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import net.minecraft.nbt.CompoundTag -import net.minecraft.server.MinecraftServer -import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionCatalog -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionSignature -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryParam -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryType - -/** - * What a story function can see while it runs. - * - * [state] is the function's own checkpoint. It is persisted with the session, so a function that - * wants to survive a restart mid-execution writes its progress there and reads it back on the next - * run; functions that do not bother are simply re-executed from the start (their statement is - * at-least-once). The tag is dropped once the call returns. - */ -interface StoryCallContext { - val session: DialogueSession - val server: MinecraftServer - val players: List - val state: CompoundTag - - /** Persists the session (including [state]) right now, without waiting for the next statement. */ - fun checkpoint() -} - -/** A registered function body. */ -fun interface StoryFunctionHandler { - suspend fun invoke(context: StoryCallContext, args: StoryArguments) -} - -/** - * The functions a story may call. Overloads share a name and are told apart by arity and argument - * types; the compiler rejects calls no overload can accept, and the final pick happens at runtime - * against the actual values. - */ -class StoryFunctionRegistry(private val parent: StoryFunctionRegistry? = null) : StoryFunctionCatalog { - private val functions = LinkedHashMap>() - - private class Registration(val signature: StoryFunctionSignature, val handler: StoryFunctionHandler) - - override fun overloads(name: String): List? { - val own = functions[name]?.map { it.signature } - val inherited = parent?.overloads(name) - return when { - own == null -> inherited - inherited == null -> own - else -> inherited + own - } - } - - val names: Set get() = functions.keys + (parent?.names ?: emptySet()) - - /** Registers one overload. Later registrations win when several fit the same call. */ - fun register(name: String, params: List, handler: StoryFunctionHandler) { - val signature = StoryFunctionSignature(name, params) - val list = functions.getOrPut(name) { mutableListOf() } - require(list.none { it.signature.params.map(StoryParam::name) == params.map(StoryParam::name) }) { - "Function '$name' already has an overload with these parameters" - } - list += Registration(signature, handler) - } - - /** - * The everyday form: parameters spelled with [string]/[number]/..., the body an extension on - * [StoryCallContext] so `players`, `state` and `actor` are simply in scope. - * - * ```kotlin - * functions.add("wait", number("time")) { args -> delay(args.millis("time")) } - * ``` - */ - fun add( - name: String, - vararg params: StoryParam, - handler: suspend StoryCallContext.(StoryArguments) -> Unit, - ) = register(name, params.toList()) { context, args -> context.handler(args) } - - /** Removes every overload of [name]; used when an addon reloads its functions. */ - fun unregister(name: String) { - functions.remove(name) - } - - /** - * Picks the overload for a call. [positional] and [named] are already evaluated, so this is the - * runtime half of resolution: the compiler guaranteed at least one candidate existed for the - * literals it could see. - */ - fun resolve( - name: String, - positional: List, - named: Map, - ): ResolvedFunction? { - val local = functions[name].orEmpty().lastOrNull { StoryArguments.fits(it.signature, positional, named) } - if (local != null) return ResolvedFunction(local.signature, local.handler) - return parent?.resolve(name, positional, named) - } - - fun has(name: String): Boolean = functions.containsKey(name) || parent?.has(name) == true - - /** - * Types come from the lambda, names from [params]: - * - * ```kotlin - * functions.add("play-video", ["name", "volume"]) { name, volume -> … } - * ``` - * - * The last [optional] parameters may be omitted by the story. - */ - inline fun add( - name: String, - params: List, - optional: Int = 0, - crossinline block: suspend StoryCallContext.(A) -> Unit, - ) { - require(params.size == 1) { "Function '$name' declares 1 parameter but got ${params.size} names" } - register(name, paramList(params, optional, 1)) { context, args -> - context.block(args.convert(params[0])) - } - } - - inline fun add( - name: String, - params: List, - optional: Int = 0, - crossinline block: suspend StoryCallContext.(A, B) -> Unit, - ) { - require(params.size == 2) { "Function '$name' declares 2 parameters but got ${params.size} names" } - register(name, paramList(params, optional, 2)) { context, args -> - context.block(args.convert(params[0]), args.convert(params[1])) - } - } - - inline fun add( - name: String, - params: List, - optional: Int = 0, - crossinline block: suspend StoryCallContext.(A, B, C) -> Unit, - ) { - require(params.size == 3) { "Function '$name' declares 3 parameters but got ${params.size} names" } - register(name, paramList(params, optional, 3)) { context, args -> - context.block(args.convert(params[0]), args.convert(params[1]), args.convert(params[2])) - } - } - - /** Builds the parameter list of a typed registration; the last [optional] ones may be omitted. */ - inline fun paramList( - names: List, - optional: Int, - arity: Int, - ): List { - val types = listOf(storyTypeOf(), storyTypeOf(), storyTypeOf()).take(arity) - return names.mapIndexed { index, name -> - StoryParam(name, types[index], optional = index >= names.size - optional) - } - } - - companion object { - inline fun storyTypeOf(): StoryType = when (T::class) { - String::class -> StoryType.STRING - Float::class, Int::class, Long::class, Double::class -> StoryType.NUMBER - Boolean::class -> StoryType.BOOL - List::class -> StoryType.LIST - else -> StoryType.ANY - } - } -} - -class ResolvedFunction(val signature: StoryFunctionSignature, val handler: StoryFunctionHandler) - -/** Converts an argument to the type a typed registration asked for. */ -inline fun StoryArguments.convert(name: String): T { - val value: Any? = when (T::class) { - String::class -> string(name) - Float::class -> number(name) - Int::class -> int(name) - Long::class -> millis(name) - Double::class -> number(name).toDouble() - Boolean::class -> bool(name) - List::class -> list(name) - else -> this[name] - } - return value as T -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryLibrary.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryLibrary.kt deleted file mode 100644 index 96d7b0c3b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryLibrary.kt +++ /dev/null @@ -1,97 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompileResult -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompiler -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryFunctionCatalog -import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryProgram -import java.util.concurrent.ConcurrentHashMap - -/** Where story text comes from. Files, addon jars, an in-memory string, or a future node graph. */ -fun interface StorySourceProvider { - /** Text of [address], or null when this provider does not have it. */ - fun read(address: String): String? -} - -class StoryLoadException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) - -/** - * Loads and compiles stories, and keeps the results. Compilation happens once per address and text: - * playback never parses. - */ -class StoryLibrary( - private val catalog: () -> StoryFunctionCatalog = { StoryFunctionCatalog.PERMISSIVE }, -) { - private val providers = mutableListOf() - private val cache = ConcurrentHashMap() - - fun addProvider(provider: StorySourceProvider) { - providers += provider - cache.clear() - } - - fun removeProvider(provider: StorySourceProvider) { - providers -= provider - cache.clear() - } - - /** Drops compiled programs; the next load re-reads and re-compiles. */ - fun invalidate() = cache.clear() - - fun invalidate(address: String) { - cache.keys.removeIf { it == address || it.startsWith("$address\u0000") } - } - - /** - * The compiled program for [address] in [locale], falling back to the base file. Returns the - * localized address alongside it, because that is what a checkpoint must record. - */ - fun load(address: String, locale: String? = null): LoadedStory { - val localized = locale?.let { localizedAddress(address, it) } - if (localized != null) { - readSource(localized)?.let { source -> - return LoadedStory(localized, locale, compile(localized, source)) - } - } - val source = readSource(address) ?: throw StoryLoadException("Story '$address' not found") - return LoadedStory(address, null, compile(address, source)) - } - - /** Loads a story that a checkpoint already resolved, without redoing locale fallback. */ - fun loadExact(address: String): StoryProgram { - val source = readSource(address) ?: throw StoryLoadException("Story '$address' not found") - return compile(address, source) - } - - private fun readSource(address: String): String? = providers.asReversed().firstNotNullOfOrNull { it.read(address) } - - private fun compile(address: String, source: String): StoryProgram { - val hash = StoryCompiler.hash(source) - cache["$address\u0000$hash"]?.let { return it } - - val result: StoryCompileResult = StoryCompiler.compile(address, source, catalog()) - val program = result.program ?: throw StoryLoadException( - "Story '$address' has errors:\n" + result.diagnostics.joinToString("\n") { " $it" }, - ) - cache["$address\u0000$hash"] = program - return program - } - - companion object { - /** - * `project:stories/example.story` + `ru_ru` -> `project:stories/example.ru_ru.story`. - * An address without an extension gets the locale appended as-is. - */ - fun localizedAddress(address: String, locale: String): String { - val dot = address.lastIndexOf('.') - val slash = address.lastIndexOf('/') - return if (dot > slash) address.substring(0, dot) + ".$locale" + address.substring(dot) - else "$address.$locale" - } - } -} - -data class LoadedStory( - val address: String, - val locale: String?, - val program: StoryProgram, -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryNpcFunctions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryNpcFunctions.kt deleted file mode 100644 index b52ba614e..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryNpcFunctions.kt +++ /dev/null @@ -1,174 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import kotlinx.coroutines.withTimeoutOrNull -import net.minecraft.world.entity.LivingEntity -import net.minecraft.world.phys.Vec3 -import ru.hollowhorizon.hollowengine.HollowEngine -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* -import ru.hollowhorizon.hollowengine.common.entities.NpcEntity -import ru.hollowhorizon.hollowengine.common.models.AnimationPlayMode -import ru.hollowhorizon.hollowengine.common.npcs.navigation.Facing -import ru.hollowhorizon.hollowengine.common.npcs.navigation.MoveOptions -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.play -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.playAndWait -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.stopAnimation -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.clearLookTarget -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.lookAt -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.move -import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.stopMoving -import kotlin.time.Duration.Companion.milliseconds - -/** - * Story commands that drive the characters a dialogue was given, over the engine's own NPC API. - */ -internal object StoryNpcFunctions { - private val OWNED = listOf( - "walk-to", "stop-walking", "look-at", "stop-looking", - "play-once", "play-looped", "play-clamped", "play-ping-pong", "stop-animation", - ) - - fun install(registry: StoryFunctionRegistry) { - OWNED.forEach(registry::unregister) - registry.movement() - registry.looking() - registry.animations() - } - - private fun StoryFunctionRegistry.movement() { - add( - "walk-to", - actor("who"), list("position"), - number("speed", default = DEFAULT_SPEED), number("distance", default = DEFAULT_DISTANCE), - number("timeout", default = DEFAULT_WALK_TIMEOUT_MILLIS), - any("facing", optional = true), - ) { args -> - args.walk(args.actor("who").name) { npc -> - val points = args.points("position") - if (points != null) npc.move(points, args.moveOptions()) else npc.move(args.vec3("position"), args.moveOptions()) - } - } - - add( - "walk-to", - actor("who"), actor("target"), - number("speed", default = DEFAULT_SPEED), number("distance", default = DEFAULT_DISTANCE), - number("timeout", default = DEFAULT_WALK_TIMEOUT_MILLIS), - any("facing", optional = true), - ) { args -> - args.walk(args.actor("who").name) { npc -> npc.move(args.entity("target"), args.moveOptions()) } - } - - add("stop-walking", actor("who")) { args -> args.npc("who").stopMoving() } - } - - /** - * Runs a walk with a deadline. Navigation retries an unreachable target forever by design, that - * is what an NPC that must eventually get there wants, but a dialogue cannot afford it: the - * story would sit on that statement with no window, no menu and no way out. Giving up is logged - * and the story moves on; `timeout=0` restores the wait-forever behavior for a script that - * really means it. - */ - private suspend fun StoryArguments.walk(who: String, block: suspend (NpcEntity) -> Unit) { - val npc = npc("who") - val timeout = millis("timeout") - if (timeout <= 0L) { - block(npc) - return - } - if (withTimeoutOrNull(timeout.milliseconds) { block(npc) } == null) { - npc.stopMoving() - HollowEngine.LOGGER.warn( - "'@walk-to' gave up after {} ms: '{}' could not reach its target, the story continues without it", - timeout, - who, - ) - } - } - - private fun StoryFunctionRegistry.looking() { - add("look-at", actor("who"), list("position"), number("time", default = DEFAULT_LOOK_MILLIS)) { args -> - args.npc("who").lookAt(args.vec3("position"), args.lookDuration()) - } - - add("look-at", actor("who"), actor("target"), number("time", default = DEFAULT_LOOK_MILLIS)) { args -> - args.npc("who").lookAt(args.entity("target")) - } - - add("stop-looking", actor("who")) { args -> args.npc("who").clearLookTarget() } - } - - private fun StoryFunctionRegistry.animations() { - add("play-once", actor("who"), string("animation"), number("fade", default = DEFAULT_FADE_MILLIS)) { args -> - args.entity("who").playAndWait(args.string("animation"), args.fade(), args.fade()) - } - - addPlayMode("play-looped", AnimationPlayMode.Loop) - addPlayMode("play-clamped", AnimationPlayMode.ClampForever) - addPlayMode("play-ping-pong", AnimationPlayMode.PingPong) - - add("stop-animation", actor("who"), string("animation"), number("fade", default = DEFAULT_FADE_MILLIS)) { args -> - args.entity("who").stopAnimation(args.string("animation"), args.fade()) - } - } - - private fun StoryFunctionRegistry.addPlayMode(name: String, mode: AnimationPlayMode) { - add(name, actor("who"), string("animation"), number("fade", default = DEFAULT_FADE_MILLIS)) { args -> - args.entity("who").play(args.string("animation"), mode, args.fade(), args.fade()) - } - } - - private fun StoryArguments.npc(name: String): NpcEntity { - val entity: LivingEntity = entity(name) - return entity as? NpcEntity - ?: throw IllegalArgumentException( - "'${actor(name).name}' is a ${entity.type.description.string}, not an engine NPC, so it cannot be told where to go", - ) - } - - private fun StoryArguments.moveOptions() = MoveOptions( - speed = number("speed").toDouble(), - arrivalDistance = number("distance").toDouble(), - facing = facing(), - ) - - /** The points of a position written `[[x, y, z], [x, y, z]]`, to walk through in turn; null for one `[x, y, z]`. */ - private fun StoryArguments.points(name: String): List? { - val items = list(name) - if (items.firstOrNull() !is StoryList) return null - return items.map { item -> - val numbers = (item as? StoryList)?.values?.map { (it as? StoryNumber)?.value?.toDouble() } - require(numbers != null && numbers.size == 3 && numbers.all { it != null }) { - "Argument '$name' must be positions like [[10, 64, 20], [15, 64, 20]]" - } - Vec3(numbers[0]!!, numbers[1]!!, numbers[2]!!) - } - } - - /** What `facing=` names to keep facing on the way: a character, or a position like [10, 64, 20]. */ - private fun StoryArguments.facing(): Facing = when { - this["facing"] == null -> Facing.Path - actorOrNull("facing") != null -> Facing.at(entity("facing")) - else -> Facing.at(vec3("facing")) - } - - private fun StoryArguments.lookDuration() = millis("time").milliseconds - - private fun StoryArguments.fade(): Double = number("fade").toDouble() / 1000.0 - - private const val DEFAULT_LOOK_MILLIS = 1_500f - private const val DEFAULT_FADE_MILLIS = 330f - - private const val DEFAULT_WALK_TIMEOUT_MILLIS = 15_000f - - /** Kept in step with [MoveOptions] so the editor shows what the NPC API would have used anyway. */ - private val DEFAULT_SPEED = MoveOptions().speed.toFloat() - private val DEFAULT_DISTANCE = MoveOptions().arrivalDistance.toFloat() -} - -private suspend fun NpcEntity.move(position: Vec3, options: MoveOptions) { - move(pos = position, options = options) -} - -private suspend fun NpcEntity.move(target: LivingEntity, options: MoveOptions) { - move(entity = target, dist = options.arrivalDistance, speed = options.speed) -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryValue.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryValue.kt deleted file mode 100644 index d2377cad7..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/StoryValue.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -/** - * A runtime value of the `.story` language. Variables are dynamically typed and limited to - * [StoryString], [StoryNumber] and [StoryBool]; [StoryList] exists only as an argument literal - * (`location=[10, 45, 5]`) and cannot be stored in a variable. - * - * Numbers are [Float] by design: dialogue counters never need double precision, and sessions keep - * a checkpoint of every variable after each statement. - */ -sealed interface StoryValue { - /** The value as presented to players inside `{...}` interpolation. */ - fun display(): String -} - -data class StoryString(val value: String) : StoryValue { - override fun display() = value -} - -data class StoryNumber(val value: Float) : StoryValue { - override fun display(): String = - if (value == value.toLong().toFloat()) value.toLong().toString() else value.toString() -} - -data class StoryBool(val value: Boolean) : StoryValue { - override fun display() = value.toString() -} - -data class StoryList(val values: List) : StoryValue { - override fun display() = values.joinToString(", ", "[", "]") { it.display() } -} - -/** - * Actors are runtime-only on purpose: an entity cannot be written into a checkpoint, so the script - * re-supplies them on every start while variables carry over. - */ -data class StoryActor(val name: String, val character: DialogueCharacter) : StoryValue { - override fun display() = character.name -} - -/** True unless the value is `false`, `0` or an empty string; lists are truthy when non-empty. */ -fun StoryValue.isTruthy(): Boolean = when (this) { - is StoryBool -> value - is StoryNumber -> value != 0f - is StoryString -> value.isNotEmpty() - is StoryList -> values.isNotEmpty() - is StoryActor -> true -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/UiDialoguePresentation.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/UiDialoguePresentation.kt deleted file mode 100644 index 45b04fd9c..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/UiDialoguePresentation.kt +++ /dev/null @@ -1,187 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue - -import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeoutOrNull -import net.minecraft.nbt.CompoundTag -import net.minecraft.resources.ResourceLocation -import net.minecraft.server.level.ServerPlayer -import ru.hollowhorizon.hollowengine.common.data.write -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser -import ru.hollowhorizon.hollowengine.common.ui.net.UiSession -import ru.hollowhorizon.hollowengine.common.ui.net.UiSessionManager -import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration.Companion.milliseconds - -/** - * Plays the dialogue in a scripted UI screen: the visual-novel presentation. - */ -class UiDialoguePresentation( - /** Screen to open; the engine's own is the default and any `.ui.kts` screen can take its place. */ - val screen: ResourceLocation = DEFAULT_SCREEN, - /** - * What [screen] declares. A plain `screen { }` is the default; point this at - * [UiSurfaceKind.ADAPTIVE] for a `surface { }` that reads as an overlay while a line is up and - * becomes a screen for the menu. - */ - val kind: UiSurfaceKind = UiSurfaceKind.SCREEN, - /** Milliseconds per character in word. */ - val charDelay: Int = 25, - /** How long the chosen option is left on screen so everyone sees what won. */ - val choiceRevealDelay: Long = 520L, -) : DialoguePresenter { - /** Concurrent because votes arrive off the story coroutine, on whatever thread read the packet. */ - private val sessions = ConcurrentHashMap() - - private var line = DialogueLineView() - private var choices = DialogueChoicesView() - - /** Read from the network thread, so a click never races the story's own writes. */ - @Volatile - private var phase = DialoguePhase.LINE - - override suspend fun onDialogueStart(session: DialogueSession) { - line = DialogueLineView() - choices = DialogueChoicesView() - phase = DialoguePhase.LINE - session.onlineParticipants.forEach { player -> open(session, player) } - } - - override suspend fun onDialogueEnd(session: DialogueSession, result: DialogueResult) { - phase = DialoguePhase.CLOSING - broadcast { it[DialogueUiKeys.Phase] = phase } - sessions.values.toList().forEach { it.close() } - sessions.clear() - } - - override suspend fun beginLine(session: DialogueSession, speaker: DialogueCharacter?) { - openMissing(session) - phase = DialoguePhase.LINE - line = DialogueLineView( - id = line.id + 1, - speaker = speaker?.name.orEmpty(), - speakerEntity = (speaker as? EntityCharacter)?.entity?.id ?: -1, - charDelay = charDelay, - ) - broadcast { - it.edit { - put(DialogueUiKeys.Phase, phase) - put(DialogueUiKeys.Line, line) - } - } - } - - override suspend fun appendText(session: DialogueSession, text: String) { - if (text.isEmpty()) return - val formatted = FormattedTextParser.parse(line.text + text) - val appendedCharacters = (formatted.visibleLength - line.visibleLength).coerceAtLeast(0) - line = line.copy( - text = line.text + text, - visibleLength = formatted.visibleLength, - awaiting = false, - ) - broadcast { it[DialogueUiKeys.Line] = line } - typeOut(session, appendedCharacters) - } - - override suspend fun waitForInput(session: DialogueSession) = awaitAdvance(session) - - override suspend fun endLine(session: DialogueSession) = awaitAdvance(session) - - override suspend fun showChoices(session: DialogueSession, options: List) { - openMissing(session) - phase = DialoguePhase.CHOICES - choices = DialogueChoicesView( - options = options.map { option -> - DialogueChoiceView( - index = option.index, - text = option.text, - icon = (option.metadata[ICON_PARAMETER] as? StoryString)?.value.orEmpty(), - ) - }, - voters = session.onlineParticipants.size, - ) - broadcast { - it.edit { - put(DialogueUiKeys.Phase, phase) - put(DialogueUiKeys.Choices, choices) - } - } - } - - override fun onVote(session: DialogueSession, votes: Map) { - if (choices.options.isEmpty()) return - choices = choices.copy(votes = votes.size, voters = session.onlineParticipants.size) - sessions.forEach { (player, ui) -> - ui[DialogueUiKeys.Choices] = choices.copy(myVote = votes[player] ?: -1) - } - } - - override suspend fun hideChoices(session: DialogueSession, chosen: Int) { - if (choices.options.isEmpty()) return - if (chosen >= 0) { - choices = choices.copy(decided = chosen) - sessions.forEach { (_, ui) -> ui[DialogueUiKeys.Choices] = choices } - delay(choiceRevealDelay.milliseconds) - } - choices = DialogueChoicesView() - broadcast { it[DialogueUiKeys.Choices] = choices } - } - - private fun openMissing(session: DialogueSession) { - session.onlineParticipants.forEach { player -> if (!sessions.containsKey(player)) open(session, player) } - sessions.keys.retainAll(session.onlineParticipants.toSet()) - } - - private fun open(session: DialogueSession, player: ServerPlayer) { - val initial = CompoundTag().apply { - write(DialogueUiKeys.Phase, phase) - write(DialogueUiKeys.Line, line) - write(DialogueUiKeys.Choices, choices) - } - sessions[player] = UiSessionManager.open(player, screen, kind, initial) { - onEvent { payload -> handle(session, player, payload) } - onClose { sessions.remove(player) } - } - } - - private fun handle(session: DialogueSession, player: ServerPlayer, payload: CompoundTag) { - when (payload.getString(DialogueUiKeys.Action)) { - DialogueUiKeys.AdvanceAction -> if (phase == DialoguePhase.LINE) session.advance(player) - DialogueUiKeys.ChooseAction -> session.choose(player, payload.getInt(DialogueUiKeys.Index)) - } - } - - private suspend fun typeOut(session: DialogueSession, characters: Int) { - val duration = characters.toLong() * charDelay - if (duration <= 0L) return - val controller = session as? DialogueController ?: run { - delay(duration.milliseconds) - return - } - - val skipped = withTimeoutOrNull(duration.milliseconds) { controller.awaitAdvance() } != null - if (skipped) { - line = line.copy(skips = line.skips + 1) - broadcast { it[DialogueUiKeys.Line] = line } - } - } - - private suspend fun awaitAdvance(session: DialogueSession) { - line = line.copy(awaiting = true) - broadcast { it[DialogueUiKeys.Line] = line } - val controller = session as? DialogueController ?: run { - delay(1_000.milliseconds) - return - } - controller.awaitAdvance() - } - - private fun broadcast(block: (UiSession) -> Unit) = sessions.values.forEach(block) - - companion object { - val DEFAULT_SCREEN: ResourceLocation = ResourceLocation.fromNamespaceAndPath("hollowengine", "dialogue") - - const val ICON_PARAMETER = "icon" - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompiler.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompiler.kt deleted file mode 100644 index 6e9135277..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompiler.kt +++ /dev/null @@ -1,483 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -import java.security.MessageDigest - -data class StoryCompileResult( - val program: StoryProgram?, - val cst: StoryFileCst, - val diagnostics: List, -) - -/** - * Turns story text into a [StoryProgram]. Runs off the game thread: the result is cached by address - * and source hash, so playback never parses anything. - * - * Structure comes from indentation. A line that opens a block (`@if`, `@while`, `@choice`, bare - * `@async`) owns the more-indented lines below it; anything else may not be indented further than its - * neighbors. - */ -class StoryCompiler( - private val address: String, - private val catalog: StoryFunctionCatalog = StoryFunctionCatalog.PERMISSIVE, -) { - private val diagnostics = StoryDiagnostics() - private val instructions = mutableListOf() - private val labels = LinkedHashMap() - - /** Section the following instructions belong to, and where it started, the anchor basis. */ - private var currentLabel: String? = null - private var sectionStart = 0 - - companion object { - /** Universal named parameters every function accepts on top of its own signature. */ - private val RESERVED_PARAMS = setOf("tag") - - fun hash(source: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(source.toByteArray(Charsets.UTF_8)) - return digest.joinToString("") { "%02x".format(it) } - } - - fun compile( - address: String, - source: String, - catalog: StoryFunctionCatalog = StoryFunctionCatalog.PERMISSIVE, - ): StoryCompileResult = StoryCompiler(address, catalog).compile(source) - } - - fun compile(source: String): StoryCompileResult { - val parsed = StoryParser.parse(source) - parsed.diagnostics.all.forEach { record(it) } - - val roots = buildTree(parsed.cst.lines) - compileNodes(roots, topLevel = true) - - val program = if (diagnostics.hasErrors) null else StoryProgram( - address = address, - sourceHash = hash(source), - instructions = instructions.toList(), - labels = labels.toMap(), - ) - return StoryCompileResult(program, parsed.cst, diagnostics.all) - } - - private fun record(diagnostic: StoryDiagnostic) { - when (diagnostic.severity) { - StorySeverity.ERROR -> diagnostics.error(diagnostic.message, diagnostic.span) - StorySeverity.WARNING -> diagnostics.warning(diagnostic.message, diagnostic.span) - } - } - - private class Node(val line: StoryLine, val children: MutableList = mutableListOf()) - - private fun indentWidth(indent: String): Int = indent.sumOf { if (it == '\t') 4 else 1 } - - private fun buildTree(lines: List): List { - val roots = mutableListOf() - val stack = ArrayDeque>() - - for (line in lines) { - if (line.kind is StoryLineKind.Blank || line.kind is StoryLineKind.CommentOnly) continue - if (line.kind is StoryLineKind.Broken) continue - - val width = indentWidth(line.indent) - while (stack.isNotEmpty() && width <= stack.last().second) stack.removeLast() - - val node = Node(line) - val parent = stack.lastOrNull()?.first - if (parent == null) { - if (width > 0 && roots.isNotEmpty()) { - error("Unexpected indentation: the line above does not open a block", line) - } - roots += node - } else { - parent.children += node - } - if (line.opensBlock) stack.addLast(node to width) - } - return roots - } - - private fun compileNodes(nodes: List, topLevel: Boolean = false) { - var i = 0 - while (i < nodes.size) { - val node = nodes[i] - if (!topLevel && node.line.kind is StoryLineKind.Label) { - error("Labels must be at the top level of the file, not inside a block", node.line) - } - i = when (node.line.kind) { - is StoryLineKind.If -> compileIfChain(nodes, i) - is StoryLineKind.Choice -> compileChoiceGroup(nodes, i) - is StoryLineKind.ElseIf, is StoryLineKind.Else -> { - error( - "'@${if (node.line.kind is StoryLineKind.Else) "else" else "else-if"}' without a matching '@if'", - node.line - ) - i + 1 - } - - else -> { - compileSimple(node) - i + 1 - } - } - } - } - - private fun compileSimple(node: Node) { - val line = node.line - when (val kind = line.kind) { - is StoryLineKind.Label -> { - if (labels.containsKey(kind.name)) { - error("Duplicate label '${kind.name}'", line) - } else { - labels[kind.name] = instructions.size - } - currentLabel = kind.name - sectionStart = instructions.size - requireNoChildren(node) - } - - is StoryLineKind.Dialogue -> { - validateTemplate(kind.text) - emit(StoryInstruction.Say(kind.speaker, kind.text, anchor(line), kind.speakerExpr)) - requireNoChildren(node) - } - - is StoryLineKind.Set -> { - emit(StoryInstruction.Set(kind.variable, kind.value, anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.While -> compileWhile(node, kind) - - is StoryLineKind.Jump -> { - emit(StoryInstruction.Jump(target(kind.target), anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Call -> { - emit(StoryInstruction.Call(target(kind.target), anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Return -> { - emit(StoryInstruction.Return(anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Command -> { - validateTemplate(kind.text) - emit(StoryInstruction.Command(kind.text, anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.FuncCall -> { - val call = resolveCall(kind, line) - emit(StoryInstruction.Invoke(call, anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Async -> compileAsync(node, kind) - - is StoryLineKind.Await -> { - emit(StoryInstruction.Await(kind.trackNames, anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Cancel -> { - emit(StoryInstruction.Cancel(kind.trackName, anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Sync -> { - emit(StoryInstruction.Sync(anchor(line))) - requireNoChildren(node) - } - - is StoryLineKind.Blank, is StoryLineKind.CommentOnly, is StoryLineKind.Broken -> Unit - is StoryLineKind.If, is StoryLineKind.ElseIf, is StoryLineKind.Else, - is StoryLineKind.Choice, - -> error("Internal: ${kind::class.simpleName} handled elsewhere", line) - } - } - - /** `@if` + its `@else-if`/`@else` siblings; returns the index just past the chain. */ - private fun compileIfChain(nodes: List, start: Int): Int { - var i = start - val endJumps = mutableListOf() - var sawElse = false - - while (i < nodes.size) { - val node = nodes[i] - val condition = when (val kind = node.line.kind) { - is StoryLineKind.If -> if (i == start) kind.condition else break - is StoryLineKind.ElseIf -> if (i == start) break else kind.condition - is StoryLineKind.Else -> if (i == start) break else null - else -> break - } - if (sawElse) { - error("'@else' must be the last branch of the chain", node.line) - break - } - - if (condition != null) { - val branchJump = emitPlaceholder(node.line) - compileNodes(node.children) - val skip = emitPlaceholder(node.line) - endJumps += skip - patch(branchJump, StoryInstruction.GotoIfFalse(condition, instructions.size, anchor(node.line))) - patchGoto(skip) - } else { - sawElse = true - compileNodes(node.children) - } - i++ - } - - val end = instructions.size - endJumps.forEach { patch(it, StoryInstruction.Goto(end, instructions[it].anchor)) } - return i - } - - private fun compileWhile(node: Node, kind: StoryLineKind.While) { - val conditionPc = instructions.size - val exitJump = emitPlaceholder(node.line) - compileNodes(node.children) - emit(StoryInstruction.Goto(conditionPc, anchor(node.line))) - patch(exitJump, StoryInstruction.GotoIfFalse(kind.condition, instructions.size, anchor(node.line))) - } - - /** Consecutive `@choice` siblings form one menu shown at once. */ - private fun compileChoiceGroup(nodes: List, start: Int): Int { - var i = start - val group = mutableListOf() - while (i < nodes.size && nodes[i].line.kind is StoryLineKind.Choice) { - group += nodes[i] - i++ - } - - val menuPc = emitPlaceholder(group.first().line) - val options = mutableListOf() - val endJumps = mutableListOf() - - for (node in group) { - val kind = node.line.kind as StoryLineKind.Choice - validateTemplate(kind.text) - val bodyStart = instructions.size - compileNodes(node.children) - endJumps += emitPlaceholder(node.line) - - var id: String? = null - var condition: StoryExpression? = null - val rest = mutableListOf() - for (arg in kind.args) { - when (arg.name) { - "id" -> id = literalString(arg) ?: run { - error("'id' of a choice must be a plain word", node.line) - null - } - - "if" -> condition = arg.expr - null -> error("'@choice' takes only named parameters after its text", node.line) - else -> rest += arg - } - } - options += MenuOption(id, kind.text, condition, rest, bodyStart, node.line.index) - } - - val end = instructions.size - endJumps.forEach { patch(it, StoryInstruction.Goto(end, instructions[it].anchor)) } - patch(menuPc, StoryInstruction.Menu(options, end, instructions[menuPc].anchor)) - - val duplicateIds = options.mapNotNull { it.id }.groupBy { it }.filterValues { it.size > 1 }.keys - duplicateIds.forEach { duplicate -> - error("Duplicate choice id '$duplicate' in one menu", group.first().line) - } - return i - } - - private fun compileAsync(node: Node, kind: StoryLineKind.Async) { - val startPc = emitPlaceholder(node.line) - val bodyStart = instructions.size - if (kind.inline != null) { - if (node.children.isNotEmpty()) { - error("'@async' with an inline command cannot also open a block", node.line) - } - emit(StoryInstruction.Invoke(resolveCall(kind.inline, node.line), anchor(node.line))) - } else { - if (node.children.isEmpty()) { - error("'@async' expects an indented block or a command on the same line", node.line) - } - compileNodes(node.children) - } - val bodyEnd = instructions.size - patch(startPc, StoryInstruction.AsyncStart(kind.trackName, bodyStart, bodyEnd, instructions[startPc].anchor)) - - val syncPc = (bodyStart until bodyEnd).firstOrNull { instructions[it] is StoryInstruction.Sync } ?: bodyEnd - for (pc in bodyStart until syncPc) { - when (instructions[pc]) { - is StoryInstruction.Say -> errorAt( - "Dialogue lines are not allowed inside '@async' before '@sync'", - instructions[pc].anchor - ) - - is StoryInstruction.Menu -> errorAt( - "Choices are not allowed inside '@async' before '@sync'", - instructions[pc].anchor - ) - - else -> {} - } - } - } - - private fun resolveCall(kind: StoryLineKind.FuncCall, line: StoryLine): StoryCall { - var tag: String? = null - val metadata = LinkedHashMap() - val positional = mutableListOf() - val named = LinkedHashMap() - - for (arg in kind.args) { - when (val name = arg.name) { - null -> { - if (named.isNotEmpty()) { - error("Positional arguments must come before named ones", line) - } - positional += arg - } - - "tag" -> tag = literalString(arg) ?: run { - error("'tag' must be a plain word or quoted string", line) - null - } - - else -> { - if (named.put(name, arg) != null) error("Duplicate argument '$name'", line) - } - } - } - - val overloads = catalog.overloads(kind.function) - if (overloads == null) { - warning("Unknown command '@${kind.function}', it must be registered before the story runs", line) - return StoryCall(kind.function, positional + named.values, tag, metadata, kind.functionSpan) - } - - if (overloads.isNotEmpty()) { - val viable = overloads.filter { matches(it, positional, named) } - if (viable.isEmpty()) { - error( - "No overload of '@${kind.function}' accepts these arguments " + "(candidates: ${ - overloads.joinToString( - ", " - ) { describe(it) } - })", - line, - ) - } else { - val declared = viable.flatMap { sig -> sig.params.map { it.name } }.toSet() - named.entries.removeIf { (name, arg) -> - if (name in declared || name in RESERVED_PARAMS) false - else { - metadata[name] = arg.expr - true - } - } - } - } - return StoryCall(kind.function, positional + named.values, tag, metadata, kind.functionSpan) - } - - private fun matches( - signature: StoryFunctionSignature, - positional: List, - named: Map, - ): Boolean { - if (positional.size > signature.params.size) return false - val bound = HashSet() - positional.forEachIndexed { index, arg -> - val param = signature.params[index] - if (!literalFits(param, arg)) return false - bound += param.name - } - for ((name, arg) in named) { - val param = signature.params.firstOrNull { it.name == name } ?: continue - if (name in bound) return false - if (!literalFits(param, arg)) return false - bound += name - } - return signature.params.none { !it.optional && it.name !in bound } - } - - /** Only literal arguments can be type-checked ahead of time; anything dynamic passes. */ - private fun literalFits(param: StoryParam, arg: StoryArg): Boolean { - val literal = arg.expr.constant ?: return true - return param.type.accepts(literal) - } - - private fun describe(signature: StoryFunctionSignature): String = - signature.params.joinToString(", ", "${signature.name}(", ")") { - "${it.name}: ${it.type.name.lowercase()}${if (it.optional) "?" else ""}" - } - - private fun literalString(arg: StoryArg): String? = - (arg.expr.constant as? ru.hollowhorizon.hollowengine.common.dialogue.StoryString)?.value - - private fun validateTemplate(template: TextTemplate) { - for (part in template.parts) { - if (part is TextPart.InlineCall && catalog.overloads(part.call.function) == null) { - diagnostics.warning( - "Unknown inline command '[${part.call.function}]'. It must be registered before the story runs.", - part.span, - ) - } - } - } - - private fun anchor(line: StoryLine) = StoryAnchor(currentLabel, instructions.size - sectionStart, line.index) - - private fun emit(instruction: StoryInstruction) { - instructions += instruction - } - - /** Reserves a slot whose target is only known once the block below it is compiled. */ - private fun emitPlaceholder(line: StoryLine): Int { - val index = instructions.size - instructions += StoryInstruction.Goto(index + 1, anchor(line)) - return index - } - - private fun patch(index: Int, instruction: StoryInstruction) { - instructions[index] = instruction - } - - private fun patchGoto(index: Int) { - patch(index, StoryInstruction.Goto(index + 1, instructions[index].anchor)) - } - - private fun target(ref: StoryRef) = StoryTarget(ref.address, ref.label, ref.span) - - private fun requireNoChildren(node: Node) { - node.children.firstOrNull()?.let { - error("This line does not open a block, so the lines below it must not be indented", it.line) - } - } - - private fun error(message: String, line: StoryLine) { - diagnostics.error(message, lineSpan(line)) - } - - private fun warning(message: String, line: StoryLine) { - diagnostics.warning(message, lineSpan(line)) - } - - private fun lineSpan(line: StoryLine): StorySpan { - val start = line.offset + line.indent.length - val end = line.offset + (line.commentStart ?: line.raw.length) - return StorySpan(start, maxOf(start, end), line.index) - } - - private fun errorAt(message: String, anchor: StoryAnchor) { - diagnostics.error(message, StorySpan(0, 0, anchor.line)) - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompletionContext.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompletionContext.kt deleted file mode 100644 index d626813b4..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCompletionContext.kt +++ /dev/null @@ -1,179 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -/** - * What the caret is sitting on, worked out by scanning the line-up to it. - */ -sealed interface StoryCompletionContext { - /** The part of the word already typed, which the offered items must start with. */ - val typed: String - - /** Plain dialogue text, or the start of a line: nothing to offer. */ - data object None : StoryCompletionContext { - override val typed: String get() = "" - } - - /** The command name itself. */ - data class Command(override val typed: String) : StoryCompletionContext - - /** Label of this file. */ - data class Label(override val typed: String) : StoryCompletionContext - - /** - * An argument of [command]. [parameter] is set once `name=` has been typed; until then the word - * could still become either a parameter name or the value at [positional], and the caller decides - * what is worth offering. - */ - data class Argument( - val command: String, - val parameter: String?, - val positional: Int, - override val typed: String, - ) : StoryCompletionContext - - /** - * Inside an interpolation or an expression, where variables live. [member] is set when the caret - * follows a dot, so what belongs there is a member of a value rather than a variable name. - */ - data class Expression(override val typed: String, val member: Boolean = false) : StoryCompletionContext -} - -/** - * Reads [line] up to [caret] and reports what belongs there. - */ -fun storyCompletionContext(line: String, caret: Int): StoryCompletionContext { - val end = caret.coerceIn(0, line.length) - var i = 0 - - while (i < end && (line[i] == ' ' || line[i] == '\t')) i++ - if (i >= end || line[i] != '@') { - return if (hasOpenBrace(line, i, end)) expressionContext(line, end) - else StoryCompletionContext.None - } - - val nameStart = ++i - while (i < end && StoryParser.isNameChar(line[i])) i++ - if (i >= end) return StoryCompletionContext.Command(line.substring(nameStart, end)) - val command = line.substring(nameStart, i) - - var parameter: String? = null - var positional = 0 - var tokenStart = -1 - var typedFrom = i - - while (i < end) { - when { - line[i] == ' ' || line[i] == '\t' -> { - if (tokenStart >= 0) { - if (parameter == null) positional++ - parameter = null - tokenStart = -1 - } - i++ - typedFrom = i - } - - line[i] == '"' -> { - val closing = skipQuoted(line, i, end) ?: return StoryCompletionContext.None - i = closing - tokenStart = 0 - } - - line[i] == '{' -> { - val closing = skipBracketed(line, i, end, '{', '}') - ?: return expressionContext(line, end) - i = closing - tokenStart = 0 - } - - line[i] == '[' -> { - val closing = skipBracketed(line, i, end, '[', ']') - ?: return StoryCompletionContext.Argument( - command, - parameter, - positional, - line.substring(i + 1, end).substringAfterLast(',').trim(), - ) - i = closing - tokenStart = 0 - } - - line[i] == '=' && tokenStart >= 0 && parameter == null -> { - parameter = line.substring(typedFrom, i) - i++ - typedFrom = i - tokenStart = -1 - } - - else -> { - if (tokenStart < 0) tokenStart = i - i++ - } - } - } - - val typed = line.substring(typedFrom.coerceAtMost(end), end) - if (command in LABEL_COMMANDS && (typed.startsWith('#') || parameter == null && positional == 0)) { - return StoryCompletionContext.Label(typed.removePrefix("#")) - } - if (command in CONDITION_COMMANDS || parameter == "if") return expressionContext(line, end) - if (command == "set" && line.indexOf('=').let { it in 0 until end }) return expressionContext(line, end) - return StoryCompletionContext.Argument(command, parameter, positional, typed) -} - -private val LABEL_COMMANDS = setOf("jump", "call") -private val CONDITION_COMMANDS = setOf("if", "else-if", "while") - -/** True when a `{` before [end] has not been closed, so the caret sits in an expression. */ -private fun hasOpenBrace(line: String, from: Int, end: Int): Boolean { - var depth = 0 - var i = from - while (i < end) { - when (line[i]) { - '\\' -> i++ - '{' -> depth++ - '}' -> if (depth > 0) depth-- - } - i++ - } - return depth > 0 -} - -/** Position just past the closing quote, or null when the string is still open. */ -private fun skipQuoted(line: String, opening: Int, end: Int): Int? { - var i = opening + 1 - while (i < end) { - when (line[i]) { - '\\' -> i++ - '"' -> return i + 1 - } - i++ - } - return null -} - -private fun skipBracketed(line: String, opening: Int, end: Int, open: Char, close: Char): Int? { - var depth = 0 - var i = opening - while (i < end) { - when (line[i]) { - '\\' -> i++ - open -> depth++ - close -> { - depth-- - if (depth == 0) return i + 1 - } - } - i++ - } - return null -} - -/** The identifier before [end], and whether it follows a dot, which decides what is typed. */ -private fun expressionContext(line: String, end: Int): StoryCompletionContext.Expression { - var start = end - while (start > 0 && (line[start - 1].isLetterOrDigit() || line[start - 1] == '_')) start-- - return StoryCompletionContext.Expression( - typed = line.substring(start, end), - member = start > 0 && line[start - 1] == '.', - ) -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCst.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCst.kt deleted file mode 100644 index 2f960875b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryCst.kt +++ /dev/null @@ -1,145 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -/** - * Lossless syntax tree of one `.story` file. - * - * The tree is line-based: every physical line keeps its raw text (indent, spacing and trailing - * comment included), so [print] reproduces the source byte-for-byte. - */ -class StoryFileCst( - val source: String, - val lines: List, -) { - fun print(): String = buildString(source.length) { - for (line in lines) { - append(line.raw) - append(line.eol) - } - } -} - -/** - * One physical line. [raw] excludes the terminator; [eol] is `"\n"`, `"\r\n"` or empty on the last - * line. [commentStart] is the offset of `//` inside [raw], if a comment is present. - */ -class StoryLine( - val index: Int, - val offset: Int, - val raw: String, - val eol: String, - val indent: String, - val kind: StoryLineKind, - val commentStart: Int? = null, -) { - val comment: String? get() = commentStart?.let { raw.substring(it) } - - /** True for lines that own an indented block underneath (`@if`, `@choice`, bare `@async`…). */ - val opensBlock: Boolean - get() = when (val k = kind) { - is StoryLineKind.If, is StoryLineKind.ElseIf, is StoryLineKind.Else, - is StoryLineKind.While, is StoryLineKind.Choice, - -> true - - is StoryLineKind.Async -> k.inline == null - else -> false - } -} - -/** Reference to a story position: `#Label`, `project:stories/other.story#Label` or a bare address. */ -data class StoryRef(val address: String?, val label: String?, val span: StorySpan) { - init { - require(address != null || label != null) { "Empty story reference" } - } - - override fun toString() = buildString { - address?.let { append(it) } - label?.let { append('#').append(it) } - } -} - -/** One argument of a command: positional (`name` == null) or named (`volume=1.0`). */ -data class StoryArg( - val name: String?, - val nameSpan: StorySpan?, - val expr: StoryExpression, - val span: StorySpan, -) - -sealed interface StoryLineKind { - /** Empty or whitespace-only line. */ - data object Blank : StoryLineKind - - /** A line holding nothing but a `//` comment. */ - data object CommentOnly : StoryLineKind - - /** The parser could not make sense of the line; [message] mirrors the emitted diagnostic. */ - data class Broken(val message: String) : StoryLineKind - - data class Label(val name: String, val nameSpan: StorySpan) : StoryLineKind - - data class If(val condition: StoryExpression) : StoryLineKind - data class ElseIf(val condition: StoryExpression) : StoryLineKind - data object Else : StoryLineKind - data class While(val condition: StoryExpression) : StoryLineKind - data class Set(val variable: String, val variableSpan: StorySpan, val value: StoryExpression) : StoryLineKind - - data class Jump(val target: StoryRef) : StoryLineKind - data class Call(val target: StoryRef) : StoryLineKind - data object Return : StoryLineKind - - data class Choice( - val text: TextTemplate, - val args: List, - ) : StoryLineKind - - /** - * `@async` opening a block ([inline] == null), or `@async ` running a single [inline] - * command on the new track. [trackName] comes from a leading `name=` argument. - */ - data class Async(val trackName: String?, val inline: FuncCall?) : StoryLineKind - - /** `@await` with no names waits for every live track. */ - data class Await(val trackNames: List) : StoryLineKind - data class Cancel(val trackName: String, val nameSpan: StorySpan) : StoryLineKind - data object Sync : StoryLineKind - - /** - * `@command `: the tail goes to the server as-is except for `{var}` - * interpolation. Brackets stay literal so entity selectors (`@e[tag=x]`) survive. - */ - data class Command(val text: TextTemplate, val textSpan: StorySpan) : StoryLineKind - - /** Any `@name args` that is not a built-in form: a call into the function registry. */ - data class FuncCall( - val function: String, - val functionSpan: StorySpan, - val args: List, - ) : StoryLineKind - - /** - * `Vitalik: Hello!`, `{player}: Hello!` or narrator text. - * - * [speaker] is the name written before the colon and [speakerExpr] the `{...}` form of it; both - * are null for narration, and never both set. [speakerSpan] covers whichever one was written. - */ - data class Dialogue( - val speaker: String?, - val speakerSpan: StorySpan?, - val text: TextTemplate, - val speakerExpr: StoryExpression? = null, - ) : StoryLineKind -} - -/** Text with variable expressions, inline commands, etc. */ -data class TextTemplate(val parts: List, val span: StorySpan) { - fun literalText(): String = parts.filterIsInstance().joinToString("") { it.text } -} - -sealed interface TextPart { - val span: StorySpan - - data class Literal(val text: String, override val span: StorySpan) : TextPart - data class Interpolation(val expr: StoryExpression, override val span: StorySpan) : TextPart - data class InlineCall(val call: StoryLineKind.FuncCall, override val span: StorySpan) : TextPart - data class WaitInput(override val span: StorySpan) : TextPart -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryDiagnostic.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryDiagnostic.kt deleted file mode 100644 index ad835021b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryDiagnostic.kt +++ /dev/null @@ -1,34 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -/** Half-open character range inside a story source, plus the 0-based line it starts on. */ -data class StorySpan(val start: Int, val end: Int, val line: Int) { - init { - require(end >= start) { "Span end $end before start $start" } - } -} - -enum class StorySeverity { ERROR, WARNING } - -data class StoryDiagnostic( - val severity: StorySeverity, - val message: String, - val span: StorySpan, -) { - override fun toString() = "${severity.name.lowercase()}@${span.line + 1}: $message" -} - -class StoryDiagnostics { - private val list = mutableListOf() - - val all: List get() = list - val errors: List get() = list.filter { it.severity == StorySeverity.ERROR } - val hasErrors: Boolean get() = list.any { it.severity == StorySeverity.ERROR } - - fun error(message: String, span: StorySpan) { - list += StoryDiagnostic(StorySeverity.ERROR, message, span) - } - - fun warning(message: String, span: StorySpan) { - list += StoryDiagnostic(StorySeverity.WARNING, message, span) - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryExpressions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryExpressions.kt deleted file mode 100644 index a1fcc8450..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryExpressions.kt +++ /dev/null @@ -1,221 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -import ru.hollowhorizon.hollowengine.common.dialogue.* -import ru.hollowhorizon.hollowengine.common.utils.expressions.* - -/** Where a name in an expression comes from: the session's variables, then its actors. */ -fun interface StoryVariables { - fun get(name: String): StoryValue? -} - -class StoryParseException(message: String, val span: StorySpan) : RuntimeException(message) - -/** Thrown by [StoryExpression.evaluate] for type errors; carries the span of the failing expression. */ -class StoryEvalException(message: String, val span: StorySpan) : RuntimeException(message) - -/** A failure inside the language, turned into a [StoryEvalException] with a span by [StoryExpression]. */ -private class StoryValueError(message: String) : RuntimeException(message) - -/** Milliseconds of a duration literal suffix (`1s`, `500ms`, `2min`, `1h`). */ -private val DURATION_SUFFIXES = mapOf( - "ms" to 1f, - "s" to 1_000f, - "sec" to 1_000f, - "min" to 60_000f, - "h" to 3_600_000f, -) - -/** - * The dialogue declarations of the expression language. - * - * Unlike the animation declarations it is dynamically typed: a variable holds whatever was last put in it, so - * every value is a [StoryValue] and what `+` means is decided when it runs. - */ -val StoryDeclarations: Declarations = Declarations { - val value = reference( - name = "value", - jvmClass = StoryValue::class.java, - truthy = { (it as StoryValue).isTruthy() }, - number = { value -> - (value as? StoryNumber)?.value - ?: throw StoryValueError("Expected a number, got ${value.story().typeName()}") - }, - ) { self -> - field("size", self) { value -> - when (value) { - is StoryList -> StoryNumber(value.values.size.toFloat()) - is StoryString -> StoryNumber(value.value.length.toFloat()) - else -> throw StoryValueError("A ${value.typeName()} has no 'size'") - } - } - field("name", self) { value -> - (value as? StoryActor)?.let { StoryString(it.character.name) } - ?: throw StoryValueError("A ${value.typeName()} has no 'name'") - } - field("uuid", self) { value -> - val actor = value as? StoryActor ?: throw StoryValueError("A ${value.typeName()} has no 'uuid'") - (actor.character as? EntityCharacter)?.let { StoryString(it.entity.uuid.toString()) } - ?: throw StoryValueError("'${actor.character.name}' is a name without an entity, so it has no uuid") - } - - operator(BinaryOp.ADD, self) { a, b -> add(a.story(), b.story()) } - operator(BinaryOp.SUBTRACT, self) { a, b -> StoryNumber(numeric(a, "-") - numeric(b, "-")) } - operator(BinaryOp.MULTIPLY, self) { a, b -> StoryNumber(numeric(a, "*") * numeric(b, "*")) } - operator(BinaryOp.DIVIDE, self) { a, b -> StoryNumber(numeric(a, "/") / numeric(b, "/")) } - operator(BinaryOp.REMAINDER, self) { a, b -> StoryNumber(numeric(a, "%") % numeric(b, "%")) } - operator(BinaryOp.LT, self) { a, b -> StoryBool(compare(a.story(), b.story(), "<") < 0) } - operator(BinaryOp.LTE, self) { a, b -> StoryBool(compare(a.story(), b.story(), "<=") <= 0) } - operator(BinaryOp.GT, self) { a, b -> StoryBool(compare(a.story(), b.story(), ">") > 0) } - operator(BinaryOp.GTE, self) { a, b -> StoryBool(compare(a.story(), b.story(), ">=") >= 0) } - operator(BinaryOp.INDEX, self) { target, index -> element(target.story(), index) } - } - - literals(StoryLiterals(value)) - - val variables = dynamic( - name = "variables", - valueType = value, - read = { owner, key -> - (owner as StoryVariables).get(key) ?: throw StoryValueError("Unknown variable '$key'") - }, - ) - property("variables", variables) { it } - receiver("variables") -} - -val StoryLanguage: Expression = Expression { - options { - casts(Casts.EXPLICIT) - unresolvedReferences(References.FAIL) - numberSuffixes(DURATION_SUFFIXES) - } - declarations(StoryDeclarations) -} - -private class StoryLiterals(override val type: ExprType) : Literals { - override fun number(value: Float): Any = StoryNumber(value) - override fun string(value: String): Any = StoryString(value) - override fun bool(value: Boolean): Any = StoryBool(value) - override fun list(items: List): Any = StoryList(items.map { it.story() }) -} - -/** - * One expression of a `.story` file, positioned in that file. - */ -class StoryExpression internal constructor( - val source: String, - val span: StorySpan, - val ast: Ast?, - val parts: List, - private val value: ObjectExpression, - val constant: StoryValue?, -) { - fun names(): List { - val found = ArrayList() - parts.forEach { found += it.names() } - ast?.walk { if (it is Ast.Name) found += it } - return found - } - - fun evaluate(variables: StoryVariables): StoryValue = try { - value.eval(variables) as? StoryValue - ?: throw StoryEvalException("Expression produced no value", span) - } catch (e: StoryValueError) { - throw StoryEvalException(e.message ?: "Bad value", span) - } -} - -/** Parses story expressions; the entry point [StoryParser] and the editor share. */ -object StoryExpressions { - fun isIdentStart(c: Char) = c.isLetter() || c == '_' - fun isIdentPart(c: Char) = c.isLetterOrDigit() || c == '_' - - /** - * Parses `text[from, to)` as one expression. - */ - fun parse(text: String, offset: Int, line: Int, from: Int, to: Int): StoryExpression { - val fragment = text.substring(from, to) - val baked = StoryLanguage.bake(fragment, offset + from) - val span = StorySpan(offset + from, offset + to, line) - - baked.diagnostics.firstOrNull { it.severity == Severity.ERROR }?.let { diagnostic -> - val at = diagnostic.span - throw StoryParseException( - diagnostic.message, - StorySpan(at.start, maxOf(at.end, at.start), line), - ) - } - return StoryExpression( - source = fragment, - span = span, - ast = baked.ast, - parts = emptyList(), - value = baked.asObject(StoryDeclarations.literals), - constant = baked.constant as? StoryValue, - ) - } - - /** Parses string as one expression, for a fragment that is already on its own. */ - fun parse(text: String, offset: Int, line: Int): StoryExpression = parse(text, offset, line, 0, text.length) - - /** A list the parser built rather than the author writing it, such as `@play x, y, z`. */ - fun list(items: List, span: StorySpan): StoryExpression { - val constants = items.map { it.constant } - return StoryExpression( - source = items.joinToString(", ") { it.source }, - span = span, - ast = null, - parts = items, - value = { variables -> StoryList(items.map { it.evaluate(variables) }) }, - constant = if (constants.all { it != null }) StoryList(constants.filterNotNull()) else null, - ) - } - - /** A value the parser produced itself, such as the bare word an argument was written as. */ - fun literal(value: StoryValue, span: StorySpan): StoryExpression = StoryExpression( - source = value.display(), - span = span, - ast = null, - parts = emptyList(), - value = { value }, - constant = value, - ) -} - -private fun Any?.story(): StoryValue = this as? StoryValue - ?: throw StoryValueError("Expected a value, got ${this ?: "nothing"}") - -private fun numeric(value: Any?, operator: String): Float = (value as? StoryNumber)?.value - ?: throw StoryValueError("Operator '$operator' expects a number, got ${value.story().typeName()}") - -private fun add(l: StoryValue, r: StoryValue): StoryValue = when { - l is StoryList && r is StoryList -> StoryList(l.values + r.values) - l is StoryList -> StoryList(l.values + r) - r is StoryList -> StoryList(listOf(l) + r.values) - l is StoryString || r is StoryString -> StoryString(l.display() + r.display()) - else -> StoryNumber(numeric(l, "+") + numeric(r, "+")) -} - -private fun compare(l: StoryValue, r: StoryValue, operator: String): Int = when { - l is StoryNumber && r is StoryNumber -> l.value.compareTo(r.value) - l is StoryString && r is StoryString -> l.value.compareTo(r.value) - else -> throw StoryValueError( - "Cannot compare ${l.typeName()} with ${r.typeName()} using '$operator'", - ) -} - -private fun element(target: StoryValue, index: Any?): StoryValue { - val list = target as? StoryList ?: throw StoryValueError("Only a list can be indexed") - val position = (index as? StoryNumber)?.value?.toInt() - ?: throw StoryValueError("A list index must be a number") - return list.values.getOrNull(position) - ?: throw StoryValueError("No element $position: the list holds ${list.values.size}") -} - -internal fun StoryValue.typeName(): String = when (this) { - is StoryString -> "string" - is StoryNumber -> "number" - is StoryBool -> "bool" - is StoryList -> "list" - is StoryActor -> "character" -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryFunctionSignature.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryFunctionSignature.kt deleted file mode 100644 index 5301a13c7..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryFunctionSignature.kt +++ /dev/null @@ -1,116 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -import ru.hollowhorizon.hollowengine.common.dialogue.* - -enum class StoryType { - STRING, NUMBER, BOOL, LIST, ACTOR, ANY; - - fun accepts(value: StoryValue): Boolean = when (this) { - ANY -> true - STRING -> value is StoryString || value is StoryNumber || value is StoryBool - NUMBER -> value is StoryNumber || (value is StoryString && value.value.toFloatOrNull() != null) - BOOL -> value is StoryBool - LIST -> value is StoryList - ACTOR -> value is StoryActor || value is StoryString - } -} - -/** - * One parameter. A parameter is required unless it has a [default]. - */ -data class StoryParam( - val name: String, - val type: StoryType = StoryType.ANY, - val optional: Boolean = false, - val default: StoryValue? = null, - /** - * The values this parameter accepts, when they are a known set, HUD layer names, animation - * play modes, etc. The editor offers them; nothing checks against them, since a story may - * legitimately name something the engine only learns about later. - */ - val suggestions: List = emptyList(), -) { - /** How the editor writes it: `volume=1.0`, or `except?=null` when there is nothing to default to. */ - fun display(): String = when { - default != null -> "$name=${default.display()}" - optional -> "$name?=null" - else -> name - } -} - -/** One overload of a story function. Overloads of the same name are told apart by arity and types. */ -data class StoryFunctionSignature( - val name: String, - val params: List, -) { - - init { - require(params.map { it.name }.toSet().size == params.size) { - "Duplicate parameter name in signature of '$name'" - } - } -} - -fun string(name: String, optional: Boolean = false, default: String? = null) = - StoryParam(name, StoryType.STRING, optional || default != null, default?.let(::StoryString)) - -fun number(name: String, optional: Boolean = false, default: Float? = null) = - StoryParam(name, StoryType.NUMBER, optional || default != null, default?.let(::StoryNumber)) - -fun boolean(name: String, optional: Boolean = false, default: Boolean? = null) = - StoryParam(name, StoryType.BOOL, optional || default != null, default?.let(::StoryBool)) - -fun list(name: String, optional: Boolean = false) = StoryParam(name, StoryType.LIST, optional) - -fun actor(name: String, optional: Boolean = false) = StoryParam(name, StoryType.ACTOR, optional) - -fun any(name: String, optional: Boolean = false, suggestions: List = emptyList()) = - StoryParam(name, StoryType.ANY, optional, suggestions = suggestions) - -fun signature(name: String, vararg params: StoryParam) = StoryFunctionSignature(name, params.toList()) - -/** - * What the compiler knows about the available functions. The runtime registry implements it; the IDE - * can pass a permissive implementation while an addon's functions are not loaded yet. - */ -interface StoryFunctionCatalog { - /** Overloads of [name], or null when the name is unknown (which the compiler reports as an error). */ - fun overloads(name: String): List? - - companion object { - /** Accepts every call, used when function knowledge is unavailable. */ - val PERMISSIVE = object : StoryFunctionCatalog { - override fun overloads(name: String) = emptyList() - } - } -} - -/** - * Declares a story functions catalog: - * - * ```kotlin - * storyCatalog { - * "play-video"(string("name")) - * "play-video"(string("name"), number("volume")) - * "wait"(number("time")) - * } - * ``` - */ -fun storyCatalog(block: StoryCatalogBuilder.() -> Unit): StoryFunctionCatalog = - StoryCatalogBuilder().apply(block).build() - -class StoryCatalogBuilder { - private val functions = LinkedHashMap>() - - /** Adds one overload of the receiver function. */ - operator fun String.invoke(vararg params: StoryParam) { - functions.getOrPut(this) { mutableListOf() } += StoryFunctionSignature(this, params.toList()) - } - - internal fun build(): StoryFunctionCatalog { - val snapshot = functions.mapValues { (_, overloads) -> overloads.toList() } - return object : StoryFunctionCatalog { - override fun overloads(name: String) = snapshot[name] - } - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryParser.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryParser.kt deleted file mode 100644 index cbe28d682..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryParser.kt +++ /dev/null @@ -1,569 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -import ru.hollowhorizon.hollowengine.common.dialogue.StoryBool -import ru.hollowhorizon.hollowengine.common.dialogue.StoryString - -data class StoryParseResult( - val cst: StoryFileCst, - val diagnostics: StoryDiagnostics, -) - -/** - * Parses `.story` text into a lossless [StoryFileCst]. Parsing never throws: lines that cannot be - * understood become [StoryLineKind.Broken] and a diagnostic, so the IDE can still show (and round-trip) - * the file. - */ -class StoryParser(private val source: String) { - private val diagnostics = StoryDiagnostics() - - companion object { - /** A bare numeric argument, sign and duration suffix included: `-171`, `2.5`, `600ms`. */ - private val BARE_NUMBER = Regex("""-?\d+(?:\.\d+)?(?:ms|s|sec|min|h)?""") - - fun isNameChar(c: Char) = c.isLetterOrDigit() || c == '_' || c == '-' - - fun parse(source: String): StoryParseResult = StoryParser(source).run() - } - - fun run(): StoryParseResult { - val lines = mutableListOf() - var offset = 0 - var index = 0 - while (offset <= source.length) { - val lineEnd = source.indexOf('\n', offset).let { if (it < 0) source.length else it } - val hasNewline = lineEnd < source.length - val rawWithCr = source.substring(offset, lineEnd) - val hasCr = rawWithCr.endsWith('\r') - val raw = if (hasCr) rawWithCr.dropLast(1) else rawWithCr - val eol = when { - !hasNewline -> "" - hasCr -> "\r\n" - else -> "\n" - } - if (!hasNewline && raw.isEmpty() && lines.isNotEmpty()) break - - lines += parseLine(index, offset, raw, eol) - index++ - if (!hasNewline) break - offset = lineEnd + 1 - } - return StoryParseResult(StoryFileCst(source, lines), diagnostics) - } - - private fun parseLine(index: Int, offset: Int, raw: String, eol: String): StoryLine { - val indentLength = raw.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) raw.length else it } - val indent = raw.substring(0, indentLength) - val commentStart = findCommentStart(raw, indentLength) - val contentEndRaw = commentStart ?: raw.length - var contentEnd = contentEndRaw - while (contentEnd > indentLength && (raw[contentEnd - 1] == ' ' || raw[contentEnd - 1] == '\t')) contentEnd-- - - val kind: StoryLineKind = if (indentLength >= contentEnd) { - if (commentStart != null) StoryLineKind.CommentOnly else StoryLineKind.Blank - } else { - try { - parseContent(index, offset, raw, indentLength, contentEnd) - } catch (e: StoryParseException) { - diagnostics.error(e.message ?: "Parse error", e.span) - StoryLineKind.Broken(e.message ?: "Parse error") - } - } - return StoryLine(index, offset, raw, eol, indent, kind, commentStart) - } - - /** - * Finds a `//` comment outside double quotes, either at the start of content or preceded by - * whitespace. `\/` escapes a slash in text lines. - */ - private fun findCommentStart(raw: String, from: Int): Int? { - var inQuotes = false - var i = from - while (i < raw.length - 1) { - when (val c = raw[i]) { - '\\' -> i++ // skip escaped char - '"' -> inQuotes = !inQuotes - '/' -> if (!inQuotes && raw[i + 1] == '/' && - (i == from || raw[i - 1] == ' ' || raw[i - 1] == '\t') - ) return i - - else -> {} - } - i++ - } - return null - } - - private fun parseContent(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - return when (raw[start]) { - '#' -> parseLabel(line, offset, raw, start, end) - '@' -> parseCommand(line, offset, raw, start, end) - else -> parseDialogue(line, offset, raw, start, end) - } - } - - private fun span(offset: Int, line: Int, from: Int, to: Int) = StorySpan(offset + from, offset + to, line) - - private fun parseLabel(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - var nameStart = start + 1 - while (nameStart < end && raw[nameStart] == ' ') nameStart++ - val name = raw.substring(nameStart, end) - if (name.isEmpty()) { - throw StoryParseException("Label name is empty", span(offset, line, start, end)) - } - if (name.any { it == ' ' || it == '\t' }) { - throw StoryParseException( - "Label name must be a single word (jump targets cannot contain spaces)", - span(offset, line, nameStart, end), - ) - } - return StoryLineKind.Label(name, span(offset, line, nameStart, end)) - } - - private fun parseCommand(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - var i = start + 1 - val nameStart = i - while (i < end && isNameChar(raw[i])) i++ - val name = raw.substring(nameStart, i) - if (name.isEmpty()) { - throw StoryParseException("Expected command name after '@'", span(offset, line, start, start + 1)) - } - val nameSpan = span(offset, line, nameStart, i) - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - val tailStart = i - - fun expr(): StoryExpression { - if (tailStart >= end) { - throw StoryParseException("'@$name' expects a condition", nameSpan) - } - return StoryExpressions.parse(raw, offset, line, tailStart, end) - } - - fun requireEmptyTail() { - if (tailStart < end) { - throw StoryParseException("'@$name' takes no arguments", span(offset, line, tailStart, end)) - } - } - - return when (name) { - "if" -> StoryLineKind.If(expr()) - "else-if", "elseif" -> StoryLineKind.ElseIf(expr()) - "else" -> { - requireEmptyTail() - StoryLineKind.Else - } - - "while" -> StoryLineKind.While(expr()) - "set" -> parseSet(line, offset, raw, tailStart, end, nameSpan) - "jump" -> StoryLineKind.Jump(parseRef(line, offset, raw, tailStart, end, name)) - "call" -> StoryLineKind.Call(parseRef(line, offset, raw, tailStart, end, name)) - "return" -> { - requireEmptyTail() - StoryLineKind.Return - } - - "choice" -> parseChoice(line, offset, raw, tailStart, end) - "async" -> parseAsync(line, offset, raw, tailStart, end) - "await" -> parseAwait(line, offset, raw, tailStart, end) - "cancel" -> parseCancel(line, offset, raw, tailStart, end, nameSpan) - "sync" -> { - requireEmptyTail() - StoryLineKind.Sync - } - - "command" -> { - if (tailStart >= end) { - throw StoryParseException("'@command' expects a vanilla command", nameSpan) - } - val template = parseTemplate(line, offset, raw, tailStart, end, allowInline = false) - StoryLineKind.Command(template, span(offset, line, tailStart, end)) - } - - else -> StoryLineKind.FuncCall(name, nameSpan, parseArgs(line, offset, raw, tailStart, end)) - } - } - - private fun parseSet(line: Int, offset: Int, raw: String, start: Int, end: Int, commandSpan: StorySpan): StoryLineKind { - var i = start - val nameStart = i - while (i < end && StoryExpressions.isIdentPart(raw[i])) i++ - if (i == nameStart) { - throw StoryParseException("'@set' expects a variable name", commandSpan) - } - val variable = raw.substring(nameStart, i) - val variableSpan = span(offset, line, nameStart, i) - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - if (i >= end || raw[i] != '=') { - throw StoryParseException("'@set' expects '=' after the variable name", variableSpan) - } - i++ - val value = StoryExpressions.parse(raw, offset, line, i, end) - return StoryLineKind.Set(variable, variableSpan, value) - } - - private fun parseRef(line: Int, offset: Int, raw: String, start: Int, end: Int, command: String): StoryRef { - if (start >= end) { - throw StoryParseException("'@$command' expects a target like #ΠœΠ΅Ρ‚ΠΊΠ° or file.story#ΠœΠ΅Ρ‚ΠΊΠ°", span(offset, line, start, start)) - } - var i = start - while (i < end && raw[i] != ' ' && raw[i] != '\t') i++ - if (i < end) { - throw StoryParseException("'@$command' target must be a single token", span(offset, line, i, end)) - } - val token = raw.substring(start, i) - val refSpan = span(offset, line, start, i) - val hash = token.indexOf('#') - val address = (if (hash < 0) token else token.substring(0, hash)).takeIf { it.isNotEmpty() } - val label = if (hash < 0) null else token.substring(hash + 1) - if (label != null && label.isEmpty()) { - throw StoryParseException("Label after '#' is empty", refSpan) - } - if (address == null && label == null) { - throw StoryParseException("Empty '@$command' target", refSpan) - } - return StoryRef(address, label, refSpan) - } - - private fun parseChoice(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - if (start >= end || raw[start] != '"') { - throw StoryParseException("'@choice' expects a quoted text first: @choice \"ВСкст\"", span(offset, line, start, minOf(start + 1, end))) - } - val closing = findClosingQuote(raw, start, end) - ?: throw StoryParseException("Unterminated choice text", span(offset, line, start, end)) - val text = parseTemplate(line, offset, raw, start + 1, closing, allowInline = false) - var i = closing + 1 - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - val args = parseArgs(line, offset, raw, i, end) - return StoryLineKind.Choice(text, args) - } - - private fun parseAsync(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - var i = start - var trackName: String? = null - if (raw.startsWith("name=", i)) { - val valueStart = i + "name=".length - var j = valueStart - while (j < end && raw[j] != ' ' && raw[j] != '\t') j++ - trackName = raw.substring(valueStart, j) - if (trackName.isEmpty()) { - throw StoryParseException("Empty track name", span(offset, line, i, j)) - } - i = j - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - } - if (i >= end) return StoryLineKind.Async(trackName, inline = null) - - if (raw[i] == '@') i++ - val nameStart = i - while (i < end && isNameChar(raw[i])) i++ - val function = raw.substring(nameStart, i) - if (function.isEmpty()) { - throw StoryParseException("Expected a command after '@async'", span(offset, line, nameStart, end)) - } - val functionSpan = span(offset, line, nameStart, i) - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - val call = StoryLineKind.FuncCall(function, functionSpan, parseArgs(line, offset, raw, i, end)) - return StoryLineKind.Async(trackName, call) - } - - private fun parseAwait(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - val names = mutableListOf() - var i = start - while (i < end) { - val tokenStart = i - while (i < end && raw[i] != ' ' && raw[i] != '\t') i++ - names += raw.substring(tokenStart, i) - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - } - return StoryLineKind.Await(names) - } - - private fun parseCancel(line: Int, offset: Int, raw: String, start: Int, end: Int, commandSpan: StorySpan): StoryLineKind { - if (start >= end) { - throw StoryParseException("'@cancel' expects a track name", commandSpan) - } - var i = start - while (i < end && raw[i] != ' ' && raw[i] != '\t') i++ - if (i < end) { - throw StoryParseException("'@cancel' expects a single track name", span(offset, line, i, end)) - } - return StoryLineKind.Cancel(raw.substring(start, i), span(offset, line, start, i)) - } - - private fun parseArgs(line: Int, offset: Int, raw: String, start: Int, end: Int): List { - val args = mutableListOf() - var i = start - while (i < end) { - while (i < end && (raw[i] == ' ' || raw[i] == '\t')) i++ - if (i >= end) break - val argStart = i - - var name: String? = null - var nameSpan: StorySpan? = null - if (StoryExpressions.isIdentStart(raw[i])) { - var j = i - while (j < end && StoryExpressions.isIdentPart(raw[j])) j++ - if (j < end && raw[j] == '=' && j + 1 <= end) { - name = raw.substring(i, j) - nameSpan = span(offset, line, i, j) - i = j + 1 - } - } - - val (expr, next) = parseArgValue(line, offset, raw, i, end, isCondition = name == "if") - i = next - args += StoryArg(name, nameSpan, expr, span(offset, line, argStart, i)) - } - return args - } - - - private fun parseArgList(line: Int, offset: Int, raw: String, open: Int, close: Int): StoryExpression { - val items = mutableListOf() - var i = open + 1 - while (i < close) { - while (i < close && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == ',')) i++ - if (i >= close) break - val (item, next) = parseArgValue(line, offset, raw, i, close, isCondition = false, insideList = true) - items += item - i = next - while (i < close && (raw[i] == ' ' || raw[i] == '\t')) i++ - if (i < close && raw[i] == ',') i++ - } - return StoryExpressions.list(items, span(offset, line, open, close + 1)) - } - - private fun parseArgValue( - line: Int, - offset: Int, - raw: String, - start: Int, - end: Int, - isCondition: Boolean, - insideList: Boolean = false, - ): Pair { - if (start >= end) { - throw StoryParseException("Expected a value", span(offset, line, start, start)) - } - when (raw[start]) { - '"' -> { - val closing = findClosingQuote(raw, start, end) - ?: throw StoryParseException("Unterminated string", span(offset, line, start, end)) - return if (isCondition) { - StoryExpressions.parse(raw, offset, line, start + 1, closing) to closing + 1 - } else { - val value = unescape(raw.substring(start + 1, closing)) - StoryExpressions.literal(StoryString(value), span(offset, line, start, closing + 1)) to closing + 1 - } - } - - '{' -> { - val closing = findClosingBracket(raw, start, end, '{', '}') - ?: throw StoryParseException("Unterminated '{'", span(offset, line, start, end)) - val expr = StoryExpressions.parse(raw, offset, line, start + 1, closing) - return expr to closing + 1 - } - - '[' -> { - val closing = findClosingBracket(raw, start, end, '[', ']') - ?: throw StoryParseException("Unterminated '['", span(offset, line, start, end)) - if (isCondition) { - return StoryExpressions.parse(raw, offset, line, start, closing + 1) to closing + 1 - } - return parseArgList(line, offset, raw, start, closing) to closing + 1 - } - } - - var i = start - while (i < end && raw[i] != ' ' && raw[i] != '\t' && !(insideList && raw[i] == ',')) i++ - val token = raw.substring(start, i) - val tokenSpan = span(offset, line, start, i) - if (isCondition) { - return StoryExpressions.parse(raw, offset, line, start, i) to i - } - val expr = when { - BARE_NUMBER.matches(token) -> StoryExpressions.parse(raw, offset, line, start, i) - token == "true" -> StoryExpressions.literal(StoryBool(true), tokenSpan) - token == "false" -> StoryExpressions.literal(StoryBool(false), tokenSpan) - else -> StoryExpressions.literal(StoryString(token), tokenSpan) - } - return expr to i - } - - private fun parseDialogue(line: Int, offset: Int, raw: String, start: Int, end: Int): StoryLineKind { - var speaker: String? = null - var speakerExpr: StoryExpression? = null - var speakerSpan: StorySpan? = null - var textStart = start - - if (raw[start] == '{') { - val closing = findClosingBracket(raw, start, end, '{', '}') - if (closing != null && closing + 1 < end && raw[closing + 1] == ':') { - speakerExpr = StoryExpressions.parse(raw, offset, line, start + 1, closing) - speakerSpan = span(offset, line, start, closing + 1) - textStart = closing + 2 - while (textStart < end && (raw[textStart] == ' ' || raw[textStart] == '\t')) textStart++ - } - } - - if (speakerExpr == null) { - val colon = raw.indexOf(':', start) - if (colon in (start + 1) until end) { - val candidate = raw.substring(start, colon) - if (candidate.all { isNameChar(it) }) { - speaker = candidate - speakerSpan = span(offset, line, start, colon) - textStart = colon + 1 - while (textStart < end && (raw[textStart] == ' ' || raw[textStart] == '\t')) textStart++ - } - } - } - - val text = parseTemplate(line, offset, raw, textStart, end, allowInline = true) - return StoryLineKind.Dialogue(speaker, speakerSpan, text, speakerExpr) - } - - private fun parseTemplate( - line: Int, - offset: Int, - raw: String, - start: Int, - end: Int, - allowInline: Boolean, - ): TextTemplate { - val parts = mutableListOf() - val literal = StringBuilder() - var literalStart = start - var i = start - - fun flushLiteral(upTo: Int) { - if (literal.isNotEmpty()) { - parts += TextPart.Literal(literal.toString(), span(offset, line, literalStart, upTo)) - literal.clear() - } - } - - while (i < end) { - when (val c = raw[i]) { - '\\' -> { - if (i + 1 < end) { - literal.append( - when (val esc = raw[i + 1]) { - 'n' -> '\n' - 't' -> '\t' - else -> esc - }, - ) - i += 2 - } else { - literal.append(c) - i++ - } - } - - '{' -> { - val closing = findClosingBracket(raw, i, end, '{', '}') - ?: throw StoryParseException("Unterminated '{' in text", span(offset, line, i, end)) - flushLiteral(i) - val expr = StoryExpressions.parse(raw, offset, line, i + 1, closing) - parts += TextPart.Interpolation(expr, span(offset, line, i, closing + 1)) - i = closing + 1 - literalStart = i - } - - '[' -> { - if (!allowInline) { - literal.append(c) - i++ - continue - } - val closing = findClosingBracket(raw, i, end, '[', ']') - ?: throw StoryParseException("Unterminated '[' in text", span(offset, line, i, end)) - flushLiteral(i) - val partSpan = span(offset, line, i, closing + 1) - var inner = i + 1 - var innerEnd = closing - while (inner < innerEnd && (raw[inner] == ' ' || raw[inner] == '\t')) inner++ - while (innerEnd > inner && (raw[innerEnd - 1] == ' ' || raw[innerEnd - 1] == '\t')) innerEnd-- - if (innerEnd - inner == 1 && raw[inner] == '-') { - parts += TextPart.WaitInput(partSpan) - } else { - if (inner >= innerEnd) { - throw StoryParseException("Empty inline command", partSpan) - } - if (raw[inner] == '@') inner++ - val nameStart = inner - while (inner < innerEnd && isNameChar(raw[inner])) inner++ - val function = raw.substring(nameStart, inner) - if (function.isEmpty()) { - throw StoryParseException("Expected an inline command name", partSpan) - } - val functionSpan = span(offset, line, nameStart, inner) - while (inner < innerEnd && (raw[inner] == ' ' || raw[inner] == '\t')) inner++ - val call = StoryLineKind.FuncCall(function, functionSpan, parseArgs(line, offset, raw, inner, innerEnd)) - parts += TextPart.InlineCall(call, partSpan) - } - i = closing + 1 - literalStart = i - } - - else -> { - literal.append(c) - i++ - } - } - } - flushLiteral(end) - return TextTemplate(parts, span(offset, line, start, end)) - } - - private fun findClosingQuote(raw: String, opening: Int, end: Int): Int? { - var i = opening + 1 - while (i < end) { - when (raw[i]) { - '\\' -> i++ - '"' -> return i - } - i++ - } - return null - } - - private fun findClosingBracket(raw: String, opening: Int, end: Int, open: Char, close: Char): Int? { - var depth = 0 - var inQuotes = false - var i = opening - while (i < end) { - when (raw[i]) { - '\\' -> i++ - '"' -> inQuotes = !inQuotes - open -> if (!inQuotes) depth++ - close -> if (!inQuotes) { - depth-- - if (depth == 0) return i - } - } - i++ - } - return null - } - - private fun unescape(text: String): String = buildString(text.length) { - var i = 0 - while (i < text.length) { - val c = text[i] - if (c == '\\' && i + 1 < text.length) { - append( - when (val esc = text[i + 1]) { - 'n' -> '\n' - 't' -> '\t' - else -> esc - }, - ) - i += 2 - } else { - append(c) - i++ - } - } - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryProgram.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryProgram.kt deleted file mode 100644 index d135f40bf..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/lang/StoryProgram.kt +++ /dev/null @@ -1,132 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.dialogue.lang - -/** - * A compiled story: a flat instruction list plus the label index. Nothing is reparsed while a - * dialogue plays. - * - * [sourceHash] identifies the exact text this program came from. A session checkpoint stores it, and - * a mismatch on resume is what triggers the rollback to label path. - */ -class StoryProgram( - val address: String, - val sourceHash: String, - val instructions: List, - val labels: Map, -) { - /** Label whose section contains [pc], i.e. the closest label at or before it. */ - fun labelAt(pc: Int): String? = instructions.getOrNull(pc)?.anchor?.label - - fun anchorOf(pc: Int): StoryAnchor = - instructions.getOrNull(pc)?.anchor ?: StoryAnchor(null, pc, -1) - - /** - * Finds the instruction matching [anchor] in this program: the label's section plus the recorded - * offset. Returns null when the label is gone (or the offset runs past its section), which is the - * caller's signal to restart the story. - */ - fun resolve(anchor: StoryAnchor): Int? { - val sectionStart = anchor.label?.let { labels[it] } ?: 0 - val pc = sectionStart + anchor.offset - if (pc !in instructions.indices) return null - if (instructions[pc].anchor.label != anchor.label) return null - return pc - } - - fun resolveLabelStart(anchor: StoryAnchor): Int? = anchor.label?.let { labels[it] } -} - -/** - * Position of an instruction, expressed the way a save can survive an edit: the label that opens its section. - */ -data class StoryAnchor(val label: String?, val offset: Int, val line: Int) - -/** Target of `@jump`/`@call`: another file, another label, or both. */ -data class StoryTarget(val address: String?, val label: String?, val span: StorySpan) - -sealed interface StoryInstruction { - val anchor: StoryAnchor - - /** Shows one line of dialogue and waits for the presenter to advance. */ - data class Say( - val speaker: String?, - val text: TextTemplate, - override val anchor: StoryAnchor, - val speakerExpr: StoryExpression? = null, - ) : StoryInstruction - - /** - * Shows every option of one `@choice` group at once and waits for the participants' decision. - * [exit] is where the group ends, also where execution goes when no option is available. - */ - data class Menu( - val options: List, - val exit: Int, - override val anchor: StoryAnchor, - ) : StoryInstruction - - data class Set(val variable: String, val value: StoryExpression, override val anchor: StoryAnchor) : StoryInstruction - - /** Internal control flow produced by `@if`/`@while`/choice bodies. */ - data class Goto(val target: Int, override val anchor: StoryAnchor) : StoryInstruction - - data class GotoIfFalse(val condition: StoryExpression, val target: Int, override val anchor: StoryAnchor) : StoryInstruction - - /** `@jump` replaces the whole frame stack. */ - data class Jump(val target: StoryTarget, override val anchor: StoryAnchor) : StoryInstruction - - /** `@call` pushes a frame that `@return` (or the end of the section) pops. */ - data class Call(val target: StoryTarget, override val anchor: StoryAnchor) : StoryInstruction - - data class Return(override val anchor: StoryAnchor) : StoryInstruction - - /** A call into the function registry. */ - data class Invoke(val call: StoryCall, override val anchor: StoryAnchor) : StoryInstruction - - /** A vanilla command, run with server permissions anchored at the dialogue's actor. */ - data class Command(val text: TextTemplate, override val anchor: StoryAnchor) : StoryInstruction - - /** - * Spawns a parallel track covering `[bodyStart, bodyEnd)` and continues the main flow at - * [bodyEnd], the body sits inline in the instruction list. - */ - data class AsyncStart( - val trackName: String?, - val bodyStart: Int, - val bodyEnd: Int, - override val anchor: StoryAnchor, - ) : StoryInstruction - - /** `@await` waits for the named tracks, or for all of them when [trackNames] is empty. */ - data class Await(val trackNames: List, override val anchor: StoryAnchor) : StoryInstruction - - data class Cancel(val trackName: String, override val anchor: StoryAnchor) : StoryInstruction - - /** - * `@sync` is barrier-preempt. The track waits for the parent flow to reach a statement boundary, - * then the rest of the track continues *as* the main flow. - */ - data class Sync(override val anchor: StoryAnchor) : StoryInstruction -} - -/** One button of a menu. [bodyStart] is where the branch begins; it ends by jumping past the group. */ -data class MenuOption( - val id: String?, - val text: TextTemplate, - val condition: StoryExpression?, - val args: List, - val bodyStart: Int, - val line: Int, -) - -/** - * A resolved function call: [args] are the parameters the signature declares, [metadata] holds named - * parameters it does not, those reach event handlers untouched, which is how `tag=` and friends work - * without every function declaring them. - */ -data class StoryCall( - val function: String, - val args: List, - val tag: String?, - val metadata: Map, - val span: StorySpan, -) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/entities/NpcEntity.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/entities/NpcEntity.kt index bf0dc4053..25320d27b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/entities/NpcEntity.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/entities/NpcEntity.kt @@ -11,6 +11,7 @@ import net.minecraft.world.entity.PathfinderMob import net.minecraft.world.entity.ai.attributes.AttributeSupplier import net.minecraft.world.entity.ai.attributes.Attributes import net.minecraft.world.entity.ai.goal.FloatGoal +import net.minecraft.world.entity.ai.goal.GoalSelector import net.minecraft.world.entity.ai.goal.MeleeAttackGoal import net.minecraft.world.item.ItemStack import net.minecraft.world.level.GameType @@ -34,7 +35,7 @@ class NpcEntity : PathfinderMob { moveControl = NpcMoveControl(this) } - val goals get() = goalSelector + val goals: GoalSelector get() = goalSelector val actions by lazy { NpcActionController(coroutineScope) } val inventory = NpcInventory() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt index aba605a0a..49756163e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/entity/EntityEvent.kt @@ -19,6 +19,11 @@ open class EntityEvent(val entity: Entity) : Event { companion object : EventHandler() } + /** The entity is being written to NBT; what goes into its data now is part of this save. */ + class Saving(entity: Entity) : EntityEvent(entity) { + companion object : EventHandler() + } + /** The entity is leaving its level for [reason]; it is still whole when this is posted. */ class Removed(entity: Entity, val reason: Entity.RemovalReason) : EntityEvent(entity) { companion object : EventHandler() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt index c30ac8d9b..246b6eaf7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/fsm/StateStorage.kt @@ -1,11 +1,9 @@ package ru.hollowhorizon.hollowengine.common.fsm -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive import net.minecraft.nbt.CompoundTag -import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope -import ru.hollowhorizon.hollowengine.common.utils.currentServer import kotlin.coroutines.CoroutineContext class StateStorage(val tag: CompoundTag) : CoroutineContext.Element { @@ -14,20 +12,11 @@ class StateStorage(val tag: CompoundTag) : CoroutineContext.Element { override val key: CoroutineContext.Key<*> get() = Key } -private fun waiter(checker: CompletableDeferred, condition: () -> Boolean) { - currentServer.coroutineScope.launch { - if (condition()) checker.complete(true) - else { - delay(50L) - waiter(checker, condition) - } +/** Polls in the caller's coroutine, so cancellation and condition failures belong to the waiter. */ +suspend fun await(condition: () -> Boolean) { + while (true) { + currentCoroutineContext().ensureActive() + if (condition()) return + delay(50L) } } - -suspend fun await(condition: () -> Boolean) { - val checker = CompletableDeferred() - - waiter(checker, condition) - - checker.await() -} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/AnimatorEdits.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/AnimatorEdits.kt index 02c47e00d..23069fc14 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/AnimatorEdits.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/AnimatorEdits.kt @@ -97,13 +97,14 @@ fun Animator.withoutState(layerId: String, stateId: String): Animator { if (controller.states.none { it.id == stateId }) return this val states = controller.states.filterNot { it.id == stateId } + val placed = withLayoutFixed(layerId).layout return withLayer( controller.copy( states = states, transitions = controller.transitions.filterNot { it.from == stateId || it.to == stateId }, entryState = controller.entryState?.takeIf { it != stateId } ?: states.firstOrNull()?.id, ) - ).copy(layout = layout - graphKey(layerId, stateId)) + ).copy(layout = placed - graphKey(layerId, stateId)) } /** @@ -118,7 +119,8 @@ fun Animator.withStateAsAnyState(layerId: String, stateId: String): Animator { .filterNot { it.to == stateId } .map { if (it.from == stateId) it.copy(from = ANY_STATE) else it } .distinctBy { it.from to it.to } - val point = nodeAt(layerId, stateId) + val placed = withLayoutFixed(layerId).layout + val point = placed[graphKey(layerId, stateId)] return withLayer( controller.copy( @@ -127,7 +129,7 @@ fun Animator.withStateAsAnyState(layerId: String, stateId: String): Animator { entryState = controller.entryState?.takeIf { it != stateId } ?: states.firstOrNull()?.id, ) ).copy( - layout = (layout - graphKey(layerId, stateId)).let { rest -> + layout = (placed - graphKey(layerId, stateId)).let { rest -> if (point == null) rest else rest + (graphKey(layerId, ANY_STATE) to point) } ) @@ -146,7 +148,8 @@ fun Animator.withoutAnyState(layerId: String): Animator { val layoutKey = graphKey(layerId, ANY_STATE) if (transitions.size == controller.transitions.size && layoutKey !in layout) return this - return withLayer(controller.copy(transitions = transitions)).copy(layout = layout - layoutKey) + val placed = withLayoutFixed(layerId).layout + return withLayer(controller.copy(transitions = transitions)).copy(layout = placed - layoutKey) } fun Animator.withEntryState(layerId: String, stateId: String): Animator { @@ -164,28 +167,35 @@ fun Animator.withTransition(layerId: String, transition: AnimationControllerTran return withLayer(controller.copy(transitions = controller.transitions + transition)) } -fun Animator.withTransitionAt( +/** The transition between two states: there is one at most, which is what tells it apart. */ +fun AnimationControllerLayerSpec.transition(from: String, to: String): AnimationControllerTransitionSpec? = + transitions.firstOrNull { it.from == from && it.to == to } + +/** With the transition from [from] to [to] replaced by [transition]. */ +fun Animator.withTransitionChanged( layerId: String, - index: Int, + from: String, + to: String, transition: AnimationControllerTransitionSpec, ): Animator { val controller = controller(layerId) ?: return this - if (index !in controller.transitions.indices) return this return withLayer( - controller.copy(transitions = controller.transitions.toMutableList().also { it[index] = transition }) + controller.copy(transitions = controller.transitions.map { if (it.from == from && it.to == to) transition else it }) ) } -fun Animator.withoutTransitionAt(layerId: String, index: Int): Animator { +fun Animator.withoutTransition(layerId: String, from: String, to: String): Animator { val controller = controller(layerId) ?: return this - if (index !in controller.transitions.indices) return this - return withLayer( - controller.copy(transitions = controller.transitions.filterIndexed { at, _ -> at != index }) - ) + return withLayer(controller.copy(transitions = controller.transitions.filterNot { it.from == from && it.to == to })) } +/** With [stateId] at [at]. The other states stay where they are seen, those placed by default too. */ fun Animator.withNodeAt(layerId: String, stateId: String, at: GraphPoint): Animator = - copy(layout = layout + (graphKey(layerId, stateId) to at)) + withLayoutFixed(layerId).let { it.copy(layout = it.layout + (graphKey(layerId, stateId) to at)) } + +/** With the place of every state of the layer written down, since a default place follows from how many are. */ +private fun Animator.withLayoutFixed(layerId: String): Animator = + copy(layout = layout + nodeLayout(layerId).mapKeys { (stateId, _) -> graphKey(layerId, stateId) }) fun Animator.nodeAt(layerId: String, stateId: String): GraphPoint? = layout[graphKey(layerId, stateId)] diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcModelPackets.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcModelPackets.kt new file mode 100644 index 000000000..d79efe521 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcModelPackets.kt @@ -0,0 +1,34 @@ +package ru.hollowhorizon.hollowengine.common.npcs + +import kotlinx.serialization.Serializable +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.player.Player +import ru.hollowhorizon.hollowengine.client.editor.WorldNpcModels +import ru.hollowhorizon.hollowengine.common.network.HollowPacket +import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler +import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs +import ru.hollowhorizon.hollowengine.common.utils.PlayerPermissions + +/** Asks which model the living NPC with the author's [id] shows, for an editor that checks what is played on it. */ +@HollowPacketHandler(HollowPacketHandler.Direction.TO_SERVER) +@Serializable +class RequestNpcModelPacket(val id: String) : HollowPacket { + override fun handle(player: Player) { + val server = (player as? ServerPlayer)?.server ?: return + if (!player.hasPermissions(PlayerPermissions.GAMEMASTER)) return + val npc = server.npcs.alive(id) + NpcModelPacket(id, npc != null, npc?.let(NpcAnimationRuntime::modelOf)).send(player) + } +} + +/** + * Which model the NPC with [id] shows. + * + * @param alive whether the world has a living NPC with [id] at all. + * @param model its model, or null when it has none. + */ +@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) +@Serializable +class NpcModelPacket(val id: String, val alive: Boolean, val model: String?) : HollowPacket { + override fun handle(player: Player) = WorldNpcModels.answer(id, alive, model) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Facing.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Facing.kt index b607f775e..dd09e606e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Facing.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Facing.kt @@ -1,30 +1,57 @@ package ru.hollowhorizon.hollowengine.common.npcs.navigation +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.minecraft.server.level.ServerLevel import net.minecraft.util.Mth import net.minecraft.world.entity.Entity import net.minecraft.world.entity.Mob import net.minecraft.world.phys.Vec3 +import java.util.UUID -/** Which way an NPC faces while it walks. Facing anything but its path, it walks sideways or backward as it has to. */ +/** + * Which way an NPC faces while it walks. Facing anything but its path, it walks sideways or backward as it has to. + * It is a value, so a move that faces somewhere can be saved and repeated. + */ +@Serializable sealed interface Facing { /** Faces the way it walks. */ + @Serializable + @SerialName("path") data object Path : Facing - /** Keeps facing the point [target] gives, an entity's or a fixed one; null lets it face its path for that tick. */ - class Toward(internal val target: () -> Vec3?) : Facing + /** Keeps facing [position]. */ + @Serializable + @SerialName("point") + data class Point(@Contextual val position: Vec3) : Facing + + /** Keeps facing the eyes of the entity [uuid] while it is in the level of the NPC, and its path while it is not. */ + @Serializable + @SerialName("entity") + data class Toward(@Contextual val uuid: UUID) : Facing /** Keeps facing [yaw], in degrees, as vanilla counts yaw. */ - class Yaw(val yaw: Float) : Facing + @Serializable + @SerialName("yaw") + data class Yaw(val yaw: Float) : Facing companion object { /** Faces [entity] for as long as it is there. */ - fun at(entity: Entity): Facing = Toward { entity.takeUnless(Entity::isRemoved)?.eyePosition } + fun at(entity: Entity): Facing = Toward(entity.uuid) /** Faces [position]. */ - fun at(position: Vec3): Facing = Toward { position } + fun at(position: Vec3): Facing = Point(position) } } +/** The point [facing] has [mob] face right now, or null when it faces its path. */ +internal fun Facing.target(mob: Entity): Vec3? = when (this) { + is Facing.Point -> position + is Facing.Toward -> (mob.level() as? ServerLevel)?.getEntity(uuid)?.takeUnless(Entity::isRemoved)?.eyePosition + is Facing.Yaw, Facing.Path -> null +} + /** A way to face that holds the body: the [yaw] to turn to, how fast in [turnSpeed] degrees a tick, and where the head looks. */ internal class HeldFacing(val yaw: Float, val turnSpeed: Float, val look: Vec3) @@ -66,7 +93,7 @@ internal class WalkFacing(private val mob: Mob) { val looking = look?.takeIf { lookLast >= now - 1 } if (looking != null && lookSince >= moveSince) return HeldFacing(yawTo(looking), lookTurn, looking) when (val facing = move) { - is Facing.Toward -> facing.target()?.let { return HeldFacing(yawTo(it), FACING_TURN, it) } + is Facing.Point, is Facing.Toward -> facing.target(mob)?.let { return HeldFacing(yawTo(it), FACING_TURN, it) } is Facing.Yaw -> return HeldFacing(facing.yaw, FACING_TURN, ahead(facing.yaw)) Facing.Path -> backstep?.let { return HeldFacing(it, FACING_TURN, ahead(it)) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt index ed28e83a6..e9537d744 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcMovement.kt @@ -1,12 +1,14 @@ package ru.hollowhorizon.hollowengine.common.npcs.navigation import kotlinx.coroutines.delay +import kotlinx.serialization.Serializable import net.minecraft.util.Mth import net.minecraft.world.entity.Entity import net.minecraft.world.level.pathfinder.Path import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.common.coroutines.Ref import ru.hollowhorizon.hollowengine.common.entities.NpcEntity +import ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.forceLoad import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds @@ -20,6 +22,11 @@ enum class UnavailableTargetPolicy { FAIL, } +/** + * How a move walks. It is a value, so a move can be saved and repeated with the same options; how the NPC finds + * its way is up to its [NavigationComponent]. + */ +@Serializable data class MoveOptions( val speed: Double = 1.0, val arrivalDistance: Double = 0.0, @@ -29,7 +36,6 @@ data class MoveOptions( val unreachableTimeoutTicks: Int = 40, val unreachable: UnreachablePolicy = UnreachablePolicy.WAIT_AND_RETRY, val unavailableTarget: UnavailableTargetPolicy = UnavailableTargetPolicy.WAIT_AND_RETRY, - val navigation: (NavigationComponent.() -> NavigationComponent)? = null, val facing: Facing = Facing.Path, val passDistance: Double = 1.0, val avoid: List = emptyList(), @@ -69,15 +75,18 @@ enum class Shortfall { BLOCKED, } -internal suspend fun NpcEntity.moveToPosition(target: () -> Vec3?, options: MoveOptions): MoveResult = +/** + * Walks to where [target] says, asked again as the NPC goes; the channel the walk holds is up to the caller. Like + * every walk, it keeps the chunks around the NPC loaded until it ends, so it finishes where no player is. + */ +suspend fun NpcEntity.moveToPosition(target: () -> Vec3?, options: MoveOptions): MoveResult = moveThrough(listOf(target), options) /** * Walks through [targets] in turn, stopping only at the last: each one before it counts as passed within * [MoveOptions.passDistance], and the NPC plans its pace along the ones still ahead, so it rounds them as turns. */ -internal suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptions): MoveResult { - npcNavigation.settingsOverride = options.navigation?.let { tune -> (navigationComponent ?: NavigationComponent()).tune() } +suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: MoveOptions): MoveResult = forceLoad { npcNavigation.facing.setMove(options.facing) npcNavigation.moveZones = options.avoid try { @@ -85,13 +94,12 @@ internal suspend fun NpcEntity.moveThrough(targets: List<() -> Vec3?>, options: val last = index == targets.lastIndex val reach = if (last) maxOf(options.arrivalDistance, MIN_ARRIVAL_DISTANCE) else options.passDistance val result = walkLeg(target, targets.subList(index + 1, targets.size), options, reach, last) - if (result != MoveResult.Arrived) return result + if (result != MoveResult.Arrived) return@forceLoad result } finishFacing(options.facing) - return MoveResult.Arrived + MoveResult.Arrived } finally { navigation.stop() - npcNavigation.settingsOverride = null npcNavigation.aim(null) npcNavigation.moveZones = emptyList() npcNavigation.facing.setMove(Facing.Path) @@ -190,7 +198,7 @@ private suspend fun NpcEntity.finishFacing(facing: Facing) { navigation.stop() repeat(MAX_FINISH_TURN_TICKS) { val faced = when (facing) { - is Facing.Toward -> facing.target()?.let { faceTowards(it) } ?: true + is Facing.Point, is Facing.Toward -> facing.target(this)?.let { faceTowards(it) } ?: true is Facing.Yaw -> turnBodyTo(facing.yaw) Facing.Path -> true } @@ -207,7 +215,11 @@ private fun NpcEntity.turnBodyTo(yaw: Float): Boolean { return abs(Mth.wrapDegrees(yaw - body)) <= 1f } -internal suspend fun NpcEntity.moveToEntity(target: Ref, options: MoveOptions): MoveResult { +/** Walks up to [target], following it while it moves and waiting for it as [MoveOptions.unavailableTarget] says. */ +suspend fun NpcEntity.moveToEntity(target: Ref, options: MoveOptions): MoveResult = + forceLoad { follow(target, options) } + +private suspend fun NpcEntity.follow(target: Ref, options: MoveOptions): MoveResult { while (true) { if (!target.isLinkAlive && options.unavailableTarget == UnavailableTargetPolicy.FAIL) { return MoveResult.TargetUnavailable diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcPathNavigation.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcPathNavigation.kt index 962eb9247..d3e6788ed 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcPathNavigation.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/NpcPathNavigation.kt @@ -27,8 +27,6 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve private var pathLook: Vec3? = null private var backstepPath: Path? = null - /** The settings of the move under way, taking the place of the NPC's own for as long as it lasts. */ - var settingsOverride: NavigationComponent? = null private var aimedTarget: Vec3? = null private var aimedVia: List = emptyList() @@ -72,7 +70,7 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve /** What the NPC's path search and its steps go by now. */ val settings: NavigationComponent - get() = settingsOverride ?: mob.navigationComponent ?: DEFAULT_SETTINGS + get() = mob.navigationComponent ?: DEFAULT_SETTINGS override fun createPathFinder(maxVisitedNodes: Int): PathFinder { val evaluator = NpcNodeEvaluator({ settings }, ::zones) @@ -125,7 +123,14 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve val command = if (currentPath == null) { pilot.idle(flight, flightSpeed(mob, IDLE_FLIGHT_SPEED)) } else { - pilot.follow(currentPath, flight, flightSpeed(mob, speedModifier), flightEnd(currentPath), stopDistance, held?.yaw) + pilot.follow( + currentPath, + flight, + flightSpeed(mob, speedModifier), + flightEnd(currentPath), + stopDistance, + held?.yaw + ) } ?: return null steeringTarget = command.aim lookAt(held?.look ?: command.aim.add(0.0, mob.eyeHeight * LOOK_HEIGHT, 0.0)) @@ -185,7 +190,8 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve movement.sidewaysSpeed.toDouble(), movement.backwardSpeed.toDouble(), ) - val look = held?.look ?: ahead.pointAt(locomotion.lookDistance(speedModifier)).add(0.0, mob.eyeHeight * LOOK_HEIGHT, 0.0) + val look = held?.look ?: ahead.pointAt(locomotion.lookDistance(speedModifier)) + .add(0.0, mob.eyeHeight * LOOK_HEIGHT, 0.0) lookAt(look) } @@ -196,8 +202,8 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve private fun lookAt(look: Vec3) { val control = mob.lookControl val ours = pathLook - val taken = control.isLookingAtTarget && - (ours == null || control.wantedX != ours.x || control.wantedY != ours.y || control.wantedZ != ours.z) + val taken = + control.isLookingAtTarget && (ours == null || control.wantedX != ours.x || control.wantedY != ours.y || control.wantedZ != ours.z) if (taken) return control.setLookAt(look.x, look.y, look.z, HEAD_TURN, HEAD_PITCH) pathLook = look @@ -245,8 +251,7 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve } if (!canCutCorner(currentPath.nextNode.type) || abs(next.y - start.y) > mob.maxUpStep()) return next return NpcNavigationGeometry.findWalkableWaypoint(level, mob, start, next) - ?: NpcNavigationGeometry.findSqueezeWaypoint(level, mob, start, next) - ?: next.also { + ?: NpcNavigationGeometry.findSqueezeWaypoint(level, mob, start, next) ?: next.also { recomputePath() } } @@ -281,8 +286,8 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve val toEndX = end.x - mob.x val toEndZ = end.z - mob.z val distance = Mth.length(toEndX, toEndZ) - val behind = distance > MIN_BACKSTEP && - (-Mth.sin(forward) * toEndX + Mth.cos(forward) * toEndZ) / distance < BACKSTEP_COS + val behind = + distance > MIN_BACKSTEP && (-Mth.sin(forward) * toEndX + Mth.cos(forward) * toEndZ) / distance < BACKSTEP_COS val reachesEnd = ahead.points.size == currentPath.nodeCount - currentPath.nextNodeIndex + 1 val flat = ahead.points.all { abs(it.y - mob.y) <= mob.maxUpStep() } facing.backstep = if (reachesEnd && flat && ahead.length <= reach && (behind || facing.isBackstepping)) { @@ -326,7 +331,8 @@ class NpcPathNavigation(level: Level, mob: Mob) : GroundPathNavigation(mob, leve } val node = currentPath.getNextEntityPos(mob) - val reached = abs(mob.x - node.x) < TAKEOFF_REACH && abs(mob.z - node.z) < TAKEOFF_REACH && abs(mob.y - node.y) < 1.0 + val reached = + abs(mob.x - node.x) < TAKEOFF_REACH && abs(mob.z - node.z) < TAKEOFF_REACH && abs(mob.y - node.y) < 1.0 val settled = !landed || mob.onGround() && (isAtRest() || !takesOff && carriesOnToward(currentPath, index + 1)) if (reached && settled) currentPath.advance() doStuckDetection(tempMobPos) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt index 83023d8c7..8f62f8039 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/navigation/Zone.kt @@ -1,30 +1,46 @@ package ru.hollowhorizon.hollowengine.common.npcs.navigation +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import net.minecraft.world.phys.AABB import net.minecraft.world.phys.Vec3 /** - * A part of the world an NPC keeps out of on its way: a block counts as in it when its middle is. + * A part of the world an NPC keeps out of on its way: a block counts as in it when its middle is. Zones are compared + * by value, so one written down and read back is the same zone. */ -sealed class Zone(val cost: Float?) { +@Serializable +sealed class Zone { + /** What walking through costs, or null when the NPC never enters. */ + abstract val cost: Float? + abstract fun contains(x: Double, y: Double, z: Double): Boolean fun contains(point: Vec3): Boolean = contains(point.x, point.y, point.z) /** Everything between two corners. */ - class Box(val bounds: AABB, cost: Float? = null) : Zone(cost) { + @Serializable + @SerialName("box") + data class Box(@Contextual val from: Vec3, @Contextual val to: Vec3, override val cost: Float? = null) : Zone() { + @Transient + val bounds = AABB(from, to) + override fun contains(x: Double, y: Double, z: Double): Boolean = bounds.contains(x, y, z) } /** Everything within [radius] blocks of [center]. */ - class Sphere(val center: Vec3, val radius: Double, cost: Float? = null) : Zone(cost) { + @Serializable + @SerialName("sphere") + data class Sphere(@Contextual val center: Vec3, val radius: Double, override val cost: Float? = null) : Zone() { override fun contains(x: Double, y: Double, z: Double): Boolean = center.distanceToSqr(x, y, z) <= radius * radius } companion object { /** The box from [from] to [to]; [cost] null never enters it. */ - fun box(from: Vec3, to: Vec3, cost: Float? = null): Zone = Box(AABB(from, to), cost) + fun box(from: Vec3, to: Vec3, cost: Float? = null): Zone = Box(from, to, cost) /** Everything within [radius] blocks of [center]; [cost] null never enters it. */ fun sphere(center: Vec3, radius: Double, cost: Float? = null): Zone = Sphere(center, radius, cost) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/registry/ServerNpcs.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/registry/ServerNpcs.kt index 99343db36..599c31004 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/registry/ServerNpcs.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/registry/ServerNpcs.kt @@ -67,10 +67,10 @@ class ServerNpcs internal constructor(private val server: MinecraftServer, file: } /** The living NPC with [id], whether it has a body or not. */ - internal fun alive(id: String): NpcEntity? = registry[id]?.takeUnless { it.isDead }?.let { resolve(it.uuid) } + fun alive(id: String): NpcEntity? = registry[id]?.takeUnless { it.isDead }?.let { resolve(it.uuid) } - /** Gives a new [body] its record and adds it to its level. */ - internal fun spawn(body: NpcEntity, id: String?) { + /** Gives a new [body] its record under [id] and adds it to its level. */ + fun spawn(body: NpcEntity, id: String?) { registry.put(recordOf(body, id), saveEntity(body))?.let { drop(it.uuid) } instances[body.uuid] = body bodies += body.uuid diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index 0760de95e..8241152c5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt @@ -83,14 +83,6 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.item", "ru.hollowhorizon.hollowengine.common.utils.rl", "ru.hollowhorizon.hollowengine.common.utils.literal", - "ru.hollowhorizon.hollowengine.common.dialogue.*", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.actor", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.any", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.boolean", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.list", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.number", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.signature", - "ru.hollowhorizon.hollowengine.common.dialogue.lang.string", ), implicitReceivers = listOf(ServerReloadContext::class), clientSideReceivers = listOf(CLIENT_RELOAD_CONTEXT), @@ -150,7 +142,6 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.coroutines.*", "ru.hollowhorizon.hollowengine.common.scripting.annotations.*", "ru.hollowhorizon.hollowengine.common.scripting.nodes.*", - "ru.hollowhorizon.hollowengine.common.scripting.state.*", "kotlinx.coroutines.delay", "kotlinx.coroutines.yield", "kotlinx.coroutines.withTimeout", @@ -201,7 +192,6 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.models.*", "ru.hollowhorizon.hollowengine.common.colliders.*", "ru.hollowhorizon.hollowengine.common.attachments.api.set", - "ru.hollowhorizon.hollowengine.common.dialogue.*", "ru.hollowhorizon.hollowengine.common.utils.rl", "ru.hollowhorizon.hollowengine.common.utils.literal", "ru.hollowhorizon.hollowengine.common.utils.sendToast", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt new file mode 100644 index 000000000..14f79f80a --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptFailedEvent.kt @@ -0,0 +1,13 @@ +package ru.hollowhorizon.hollowengine.common.scripting + +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId + +/** + * A script instance of [id] could not start on the server: the script is gone, does not compile, or threw while + * starting. Addons drop what waited for such an instance instead of waiting forever. + */ +class ScriptFailedEvent(val id: ScriptId) : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt index f78de40ed..d7b9a45a9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptLoader.kt @@ -9,6 +9,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.compiling.loadKotlinCompil import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptArtifacts import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptTranslators import java.io.File import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass @@ -30,6 +31,7 @@ object ScriptLoader { ?: return Result.failure(NoSuchElementException("Unknown script '${ScriptRegistry.display(id)}'")) val fingerprint = ScriptFingerprint.compute(id) val cached = ScriptCache.artifact(id) + ScriptTranslators.bind(id) currentArtifact(artifacts, fingerprint, cached)?.let { jar -> return runCatching { load(id, jar) } @@ -43,7 +45,9 @@ object ScriptLoader { cacheOutput = fingerprint?.let { cached }, cacheFingerprint = fingerprint, ) - return scripting.compiler.compile(artifacts.sourceFile, context).onSuccess { + val source = runCatching { ScriptTranslators.kotlinSource(id, artifacts.sourceFile) } + .getOrElse { return Result.failure(it) } + return scripting.compiler.compile(source, context).onSuccess { // The artifact at this path has just been rewritten, so an earlier rejection of it // says nothing about the new one. rejectedArtifacts -= cached.canonicalPath @@ -113,12 +117,12 @@ object ScriptLoader { * Compiles every known script so a pack can be shipped to players who have no compiler addon. * Returns the scripts that failed, with the reason. */ - fun compileAll(ids: Collection = ScriptRegistry.list(SCRIPT_EXTENSION)): Map { + fun compileAll(ids: Collection = ScriptTranslators.compilable()): Map { val failures = LinkedHashMap() ids.forEach { id -> compile(id).onFailure { failures[id] = it } } - ScriptCache.prune(ScriptRegistry.list(SCRIPT_EXTENSION)) + ScriptCache.prune(ScriptTranslators.compilable()) return failures } @@ -157,12 +161,10 @@ object ScriptLoader { } } - private fun load(id: ScriptId, jar: File): CompiledScript { - val classLoader = ScriptRegistry.source(id.namespace)?.classLoader - return jar.loadKotlinCompiledScriptFromJar(classLoader) - } + /** Loads a compiled artifact in the classloader a fresh compilation would run in, dependencies included. */ + private fun load(id: ScriptId, jar: File): CompiledScript = + jar.loadKotlinCompiledScriptFromJar(ScriptRegistry.classLoader(id.namespace)) - private const val SCRIPT_EXTENSION = ".kts" private const val MAX_CAUSE_DEPTH = 8 } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt new file mode 100644 index 000000000..0d0c192a5 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ScriptStartedEvent.kt @@ -0,0 +1,22 @@ +package ru.hollowhorizon.hollowengine.common.scripting + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId + +/** + * A script instance has run its body and is about to start: a node, a reload script or a startup script. + * Addons pick up the instances they work with here. + * + * @param scope lives exactly as long as the instance does. + * @param entry what the instance was asked to start with, such as the stage named by `/hollowengine scripting run`. + */ +class ScriptStartedEvent( + val id: ScriptId, + val script: Any, + val scope: CoroutineScope, + val entry: String? = null, +) : Event { + companion object : EventHandler() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt index ca45cb53e..f2a8c0607 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/annotations/Annotations.kt @@ -45,5 +45,3 @@ annotation class ClientSide @Target(AnnotationTarget.FILE) annotation class ServerSide -@Target(AnnotationTarget.FUNCTION) -annotation class State(val name: String = "") diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptCache.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptCache.kt index e3c0fd836..19c9503f2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptCache.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptCache.kt @@ -57,6 +57,8 @@ object ScriptCache { /** Shared script classes are located next to the root artifact of that same script, under this suffix. */ const val SHARED_ARTIFACT_SUFFIX = ".shared.jar" + private const val GENERATED_SUFFIX = ".kts" + fun artifact(id: ScriptId): File = artifactIn(cacheDirectory(id), id) /** Classes of one shared script, referenced by every artifact that imports it. */ @@ -69,6 +71,9 @@ object ScriptCache { */ fun sharedArtifactIn(directory: File, id: ScriptId): File = directory.resolve(id.path + SHARED_ARTIFACT_SUFFIX) + /** Where the Kotlin generated for the script [id] is kept, see `ScriptTranslators`. */ + fun generatedSource(id: ScriptId): File = cacheDirectory(id).resolve(id.path + GENERATED_SUFFIX) + private fun cacheDirectory(id: ScriptId): File = DirectoryManager.SCRIPT_CACHE.resolve(id.namespace) /** What [jar] was compiled from, or `null` if it is missing. */ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt index 2964e60bc..5efef51fd 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/cache/ScriptFingerprint.kt @@ -21,7 +21,7 @@ import java.security.MessageDigest */ object ScriptFingerprint { /** Bumped whenever the layout of a cached artifact changes in a way older jars cannot satisfy. */ - const val FORMAT_VERSION = 3 + const val FORMAT_VERSION = 5 /** What a build compiles addon scripts for: Mojang names, which is what NeoForge runs on. */ const val NAMED_PRODUCTION_RUNTIME = "neoforge/official/production" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt index dfc3e3b72..a49afa031 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/KotlinCompiledScriptJar.kt @@ -57,7 +57,9 @@ internal class KotlinCompiledScriptJar( private val evaluator = HollowEngineScriptEvaluator() private val scriptClass = lazy { runBlocking { - (script.getClass(evaluationConfiguration) as ResultWithDiagnostics.Success).value + script.getClass(evaluationConfiguration).valueOr { failure -> + throw ScriptEvaluationException(name, failure.reports.map { it.convert() }) + } } } @@ -102,6 +104,10 @@ internal val JvmScriptEvaluationConfigurationKeys.actualClassLoader by Propertie /** Whether this compiled script opted into instance sharing with `@file:SharedScript`. */ val ScriptCompilationConfigurationKeys.isSharedScript by PropertiesCollection.key() +/** Frozen at compilation, so editing the sources cannot relabel already compiled bytecode. */ +val ScriptCompilationConfigurationKeys.sharedScriptId by PropertiesCollection.key() +val ScriptCompilationConfigurationKeys.sharedScriptFingerprint by PropertiesCollection.key() + /** Whether this compiled script declared `@file:ClientSide`. */ val ScriptCompilationConfigurationKeys.isClientSideScript by PropertiesCollection.key() @@ -132,7 +138,6 @@ open class HollowEngineScriptEvaluator : ScriptEvaluator { compiledScript.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] == true if (canShareInstance) { - if (!imported) SharedScriptClasses.adopt(compiledScript, scriptClass) SharedScriptClasses.instanceOf(scriptClass)?.asSuccess()?.let { return@evaluation it } } @@ -303,7 +308,7 @@ internal class KJvmCompiledScriptFromJar( val script = createScriptFromClassLoader(scriptClassName, classLoader) classLoader.shareClassesOf( SharedScriptClasses.loadersFor( - script.otherScripts, + listOf(script), entries, baseClassLoader, ScriptCache.sharedScriptsOf(file), @@ -379,5 +384,5 @@ fun ScriptDiagnostic.convert(): Diagnostic { ScriptDiagnostic.Severity.WARNING -> Severity.WARNING ScriptDiagnostic.Severity.ERROR -> Severity.ERROR ScriptDiagnostic.Severity.FATAL -> Severity.FATAL - }, message) + }, exception?.message?.takeUnless { it.isBlank() || message.contains(it) }?.let { "$message: $it" } ?: message) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt index 7b1593a4e..426884207 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptClasses.kt @@ -5,6 +5,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache.SharedScriptRef import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptFingerprint import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses.clearInstances +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import java.io.File import java.util.jar.JarFile @@ -23,15 +24,13 @@ import kotlin.script.experimental.jvm.impl.KJvmCompiledScript * file, and `@file:SharedScript` could only share an instance inside a single import graph. */ object SharedScriptClasses { - private class Entry(val identity: String, val loader: ClassLoader) - - /** Bytecode of one shared script, and what identifies the revision it came from. */ - private class Classes(val identity: String, val entries: Map) + private class Entry(val reference: SharedScriptRef, val loader: ClassLoader, val dependencies: List) { + val identity: String get() = reference.fingerprint + } - private val loaders = HashMap() + private val loaders = HashMap() private val instances = HashMap, EvaluationResult>() - private val kept = HashSet>() - private val reportedMismatches = HashSet() + private val kept = HashSet() /** * Where the classes of shared scripts among [imports] have to be looked up, by class name. @@ -46,9 +45,9 @@ object SharedScriptClasses { parent: ClassLoader?, declared: Map = emptyMap(), ): Map { - val result = LinkedHashMap() + val result = LinkedHashMap() imports.forEach { collect(it, entries, parent, declared, result) } - return result + return result.mapValues { it.value.loader } } private fun collect( @@ -56,82 +55,106 @@ object SharedScriptClasses { entries: Map, parent: ClassLoader?, declared: Map, - result: LinkedHashMap, + result: LinkedHashMap, ) { script.otherScripts.forEach { collect(it, entries, parent, declared, result) } if (script.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] != true) return - val name = (script as? KJvmCompiledScript)?.scriptClassFQName ?: return - if (name in result) return - - // No stable identity means no safe sharing: better a private copy than a wrong one. - val reference = declared[name] ?: referenceOf(script) ?: return - val existing = loaders[name] + val jvmScript = script as? KJvmCompiledScript + ?: fail("Shared script '${script.sourceLocationId}' has no runtime-compatible JVM class metadata: " + + "actual type=${script.javaClass.name}, actual loader=${script.javaClass.classLoader}, " + + "expected loader=${KJvmCompiledScript::class.java.classLoader}") + val name = jvmScript.scriptClassFQName + script.otherScripts.forEach { dependency -> + if (dependency.compilationConfiguration[ScriptCompilationConfiguration.isSharedScript] != true) { + fail(sharedScriptImportError(script.sourceLocationId.orEmpty(), dependency.sourceLocationId.orEmpty())) + } + } + val recorded = storedReferenceOf(jvmScript) + val declaration = declared[name] + if (recorded != null && declaration != null && recorded != declaration) { + fail("Conflicting shared script metadata for '$name': serialized identity=$recorded, artifact manifest=$declaration. " + + "Rebuild the artifact and its shared dependencies together.") + } + val reference = declaration ?: recorded ?: referenceOf(jvmScript) + ?: fail("Cannot resolve shared script identity: class='$name', source='${script.sourceLocationId}', " + + "registered namespaces=${ScriptRegistry.sources().map { it.namespace }}. " + + "Register the source before compilation or rebuild the compiled artifact; refusing to create a private copy.") + result[name]?.let { resolved -> + val previous = resolved.reference + if (previous != reference) { + fail("Shared script class '$name' has conflicting identities in one import graph: '$previous' and '$reference'. " + + "Use distinct packages for scripts declaring the same class name. Existing loader=${resolved.loader}.") + } + return + } + val existing = loaders[reference.id] if (existing != null && existing.identity == reference.fingerprint) { - result[name] = existing.loader + HollowEngine.LOGGER.debug("Reusing shared script '{}', class={}, fingerprint={}, loader={}", + reference.id.qualified, name, reference.fingerprint, existing.loader) + result[name] = existing return } - val classes = classesOf(reference, entries) ?: return - - val entry = if (existing != null && existing.identity == classes.identity) { - existing - } else { - val delegates = result.toMap() - Entry( - classes.identity, MemoryClassLoader(classes.entries, parent).apply { shareClassesOf(delegates) }).also { - loaders[name] = it + if (existing != null) { + if (existing.loader in kept) { + fail("Cannot replace startup/shared script '${reference.id.qualified}' while its instance is retained: " + + "old fingerprint=${existing.identity}, new fingerprint=${reference.fingerprint}, loader=${existing.loader}. Restart the game.") } } - result[name] = entry.loader + val classes = classesOf(reference, entries) + val loader = MemoryClassLoader(classes, parent).apply { shareClassesOf(result.mapValues { it.value.loader }) } + val dependencies = script.otherScripts.filterIsInstance() + .mapNotNull { result[it.scriptClassFQName] } + val entry = Entry(reference, loader, dependencies) + loaders[reference.id] = entry + if (existing != null) { + HollowEngine.LOGGER.warn("Shared script '{}' changed identity: class={}, old fingerprint={}, new fingerprint={}, old loader={}, new loader={}", + reference.id.qualified, name, existing.identity, reference.fingerprint, existing.loader, loader) + } + HollowEngine.LOGGER.debug("Created shared script loader: script={}, class={}, fingerprint={}, parent={}, loader={}", + reference.id.qualified, name, reference.fingerprint, parent, loader) + result[name] = entry } - /** - * Makes the classes of a shared script that runs on its own the ones its importers link against, so - * they get the instance it created. Without this the root and an importer would each define their own - * class for one file, and an instance remembered for one is invisible to the other. - */ - @Synchronized - fun adopt(script: CompiledScript, scriptClass: KClass<*>) { - val identity = when (script) { - is KJvmCompiledScriptFromJar -> script.fingerprint - is KJvmCompiledScript -> referenceOf(script)?.fingerprint - else -> null - } ?: return - val name = scriptClass.java.name - if (loaders[name]?.identity == identity) return - loaders[name] = Entry(identity, scriptClass.java.classLoader) + private fun storedReferenceOf(script: KJvmCompiledScript): SharedScriptRef? { + val configuration = script.compilationConfiguration + val storedId = configuration[ScriptCompilationConfiguration.sharedScriptId] + val storedFingerprint = configuration[ScriptCompilationConfiguration.sharedScriptFingerprint] + if (storedId != null && storedFingerprint != null) { + return SharedScriptRef(script.scriptClassFQName, ScriptRegistry.parse(storedId), storedFingerprint) + } + return null } private fun referenceOf(script: KJvmCompiledScript): SharedScriptRef? { val location = script.sourceLocationId ?: return null - val id = ScriptRegistry.idOf(File(location)) ?: runCatching { ScriptRegistry.parse(location) }.getOrNull() - ?.takeIf { ScriptRegistry.artifacts(it) != null } ?: return null - val fingerprint = runCatching { ScriptFingerprint.compute(id) }.getOrNull() ?: return null + val id = ScriptRegistry.idOfLocation(location) ?: return null + val fingerprint = runCatching { ScriptFingerprint.compute(id) } + .onFailure { HollowEngine.LOGGER.error("Cannot compute shared script identity for '${id.qualified}' at '$location'", it) } + .getOrNull() ?: return null return SharedScriptRef(script.scriptClassFQName, id, fingerprint.code) } - private fun classesOf(reference: SharedScriptRef, fallback: Map): Classes? { + private fun classesOf(reference: SharedScriptRef, fallback: Map): Map { val artifact = artifactFor(reference) - if (artifact != null) { - val provided = ScriptCache.hashOf(artifact) - if (provided != reference.fingerprint) reportMismatch(reference, artifact, provided) + if (artifact != null && ScriptCache.hashOf(artifact) == reference.fingerprint) { return runCatching { - Classes( - provided ?: artifact.identity(), entriesOf(artifact) - ) - }.onFailure { HollowEngine.LOGGER.error("Unreadable shared script '{}'", artifact, it) }.getOrNull() + entriesOf(artifact) + }.getOrElse { fail("Unreadable shared script '$artifact': ${it.message}") } } if (fallback.containsKey(classFileOf(reference.scriptClass))) { - return Classes(reference.fingerprint, fallback) + val prefix = reference.scriptClass.replace('.', '/') + return fallback.filterKeys { it == "$prefix.class" || it.startsWith("$prefix$") } } - HollowEngine.LOGGER.error( - "Shared script '{}' has no compiled classes: neither '{}' nor an artifact shipped with it exists, and the artifacts importing it carry no copy. Reinstall the pack, or install HollowEngineCompiler Addon to rebuild it.", - ScriptRegistry.display(reference.id), - ScriptCache.sharedArtifact(reference.id), - ) - return null + if (artifact != null) { + fail("Shared script '${reference.id.qualified}' artifact '$artifact' has fingerprint '${ScriptCache.hashOf(artifact)}', " + + "expected '${reference.fingerprint}'. Rebuild its importers and shared artifact together.") + } + + fail("Shared script '${reference.id.qualified}' has no compiled classes: neither '${ScriptCache.sharedArtifact(reference.id)}' " + + "nor a bundled artifact or in-memory copy exists. Reinstall the pack or rebuild it with the compiler addon.") } private fun artifactFor(reference: SharedScriptRef): File? { @@ -142,17 +165,9 @@ object SharedScriptClasses { return candidates.firstOrNull { ScriptCache.hashOf(it) == reference.fingerprint } ?: candidates.firstOrNull() } - private fun File.identity(): String = "$canonicalPath@${lastModified()}:${length()}" - - private fun reportMismatch(reference: SharedScriptRef, artifact: File, provided: String?) { - if (!reportedMismatches.add("${reference.id.qualified}=$provided")) return - HollowEngine.LOGGER.warn( - "Compiled shared script '{}' is not the one the scripts importing it were built against ({} holds {} instead of {}); linking against it anyway", - ScriptRegistry.display(reference.id), - artifact, - provided ?: "no fingerprint", - reference.fingerprint, - ) + private fun fail(message: String): Nothing { + HollowEngine.LOGGER.error(message) + throw IllegalStateException(message) } private fun entriesOf(artifact: File): Map = JarFile(artifact).use { jar -> @@ -173,12 +188,17 @@ object SharedScriptClasses { /** Keeps the instance of [scriptClass] through [clearInstances], for scripts that cannot run twice. */ @Synchronized fun keepInstance(scriptClass: KClass<*>) { - kept += scriptClass + fun keep(entry: Entry) { + if (!kept.add(entry.loader)) return + entry.dependencies.forEach(::keep) + } + val loader = scriptClass.java.classLoader + loaders.values.firstOrNull { it.loader === loader }?.let(::keep) ?: kept.add(loader) } /** Forgets the state of shared scripts, so it does not move into the next world. */ @Synchronized fun clearInstances() { - instances.keys.retainAll(kept) + instances.keys.removeIf { it.java.classLoader !in kept } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt new file mode 100644 index 000000000..953ec2b78 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/compiling/SharedScriptPolicy.kt @@ -0,0 +1,6 @@ +package ru.hollowhorizon.hollowengine.common.scripting.compiling + +/** Shared bytecode must never link against per-importer classes, including implementation details. */ +fun sharedScriptImportError(script: String, dependency: String): String = + "Shared script '$script' imports non-shared script '$dependency'. " + + "Add @file:SharedScript to the dependency; all transitive imports must be shared as well." diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt index 7fc97b71d..4f642cbc5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/execution/ScriptRunService.kt @@ -15,11 +15,10 @@ import ru.hollowhorizon.hollowengine.common.scripting.DefaultScriptDefinitions import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeManager import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript -import ru.hollowhorizon.hollowengine.common.scripting.nodes.addNode -import ru.hollowhorizon.hollowengine.common.scripting.nodes.nodeStateFunctions +import ru.hollowhorizon.hollowengine.common.scripting.nodes.nodeSave +import ru.hollowhorizon.hollowengine.common.scripting.nodes.startNode import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext import ru.hollowhorizon.hollowengine.common.utils.PlayerPermissions import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.coroutineContext @@ -115,11 +114,8 @@ class ScriptRunService( tracker.markRunning(path) if (node) { require(compiled.implicitReceiverCount == 1) { "An attached node must be started on its entity" } - require(state == null || state in compiled.type.java.nodeStateFunctions()) { "Unknown node state: $state" } if (nodes.contains(path) || nodes.isStopping(path)) return@start - server.addNode( - path, tag = null, context = state?.let { StateContext(nextState = it) }, compiled = compiled - ) + server.startNode(path, nodeSave(null), entry = state, compiled = compiled) check(nodes.contains(path)) { "Node initialization failed; see the server log" } } else ScriptLoader.executeCompiled(id, precompiled = compiled).getOrThrow() } catch (cancelled: CancellationException) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/AnimationClips.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/AnimationClips.kt new file mode 100644 index 000000000..265fa76d6 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/AnimationClips.kt @@ -0,0 +1,15 @@ +package ru.hollowhorizon.hollowengine.common.scripting.ide + +/** What the analyzer asks of the game to check names of clips. */ +interface AnimationClips { + /** The clips of [model], or null while it loads or when it cannot be read. */ + fun of(model: String): List? + + /** The model the NPC of the world with the author's [id] shows, or null while that is not known. */ + fun modelOfNpc(id: String): String? + + object None : AnimationClips { + override fun of(model: String): List? = null + override fun modelOfNpc(id: String): String? = null + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt index 23dd8d04a..f88307b48 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/EditorStructure.kt @@ -16,16 +16,31 @@ data class CodeFold( val collapsedByDefault: Boolean = false, ) -/** A [navigable] marker leads somewhere through [ScriptingAnalyzer.gutterTargets]. */ -enum class GutterMarkerKind(val navigable: Boolean) { +/** + * A [navigable] marker leads somewhere through [ScriptingAnalyzer.gutterTargets]; [icon] names its picture when + * it shares one with another kind. + */ +enum class GutterMarkerKind(val navigable: Boolean, val icon: String? = null) { OVERRIDE(true), COROUTINE(false), EVENT(false), + STAGE(false), + CHECKPOINT(false), + ACTION(false), + ACTION_CALL(false, "action"), + REWIND(true), + REWIND_START(false, "rewind"), + TEMPORARY(true), + TEMPORARY_OPEN(false, "temporary"), } data class GutterNavigationTarget(val label: String, val location: DefinitionLocation) -data class GutterMarker(val offset: Int, val kind: GutterMarkerKind) +/** + * [args] fill in the tooltip of the kind, such as the name of a checkpoint. [color] tints the icon, as ARGB: the + * checkpoints and the calls that lead to each share one. + */ +data class GutterMarker(val offset: Int, val kind: GutterMarkerKind, val args: List = emptyList(), val color: Int? = null) enum class ScriptRunKind { PLAIN, NODE } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/KotlinLexicalContext.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/KotlinLexicalContext.kt index c41499051..4d9a19bf5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/KotlinLexicalContext.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/KotlinLexicalContext.kt @@ -2,7 +2,12 @@ package ru.hollowhorizon.hollowengine.common.scripting.ide internal fun String.isKotlinCodeAt(offset: Int): Boolean = kotlinLexerModeAt(offset) == KotlinLexerMode.CODE -internal fun String.kotlinLexerModeAt(offset: Int): KotlinLexerMode { +internal fun String.kotlinLexerModeAt( + offset: Int, + onCodeCharacter: ((Int) -> Unit)? = null, + onLineStart: ((Int, KotlinLexerMode) -> Unit)? = null, + onLiteralEnd: ((Int) -> Unit)? = null, +): KotlinLexerMode { val limit = offset.coerceIn(0, length) var index = 0 var mode = KotlinLexerMode.CODE @@ -11,6 +16,7 @@ internal fun String.kotlinLexerModeAt(offset: Int): KotlinLexerMode { val templates = ArrayDeque() while (index < limit) { + if (index == 0 || this[index - 1] == '\n') onLineStart?.invoke(index, mode) when (mode) { KotlinLexerMode.CODE -> when { startsWith("//", index) -> { @@ -40,12 +46,14 @@ internal fun String.kotlinLexerModeAt(offset: Int): KotlinLexerMode { } this[index] == '{' && templates.isNotEmpty() -> { + onCodeCharacter?.invoke(index) templates.last().braceDepth++ index++ } this[index] == '}' && templates.isNotEmpty() -> { val template = templates.last() + if (template.braceDepth > 1) onCodeCharacter?.invoke(index) template.braceDepth-- index++ if (template.braceDepth == 0) { @@ -54,12 +62,16 @@ internal fun String.kotlinLexerModeAt(offset: Int): KotlinLexerMode { } } - else -> index++ + else -> { + onCodeCharacter?.invoke(index) + index++ + } } KotlinLexerMode.REGULAR_STRING -> when { this[index] == '\\' -> index = (index + 2).coerceAtMost(limit) this[index] == '"' -> { + onLiteralEnd?.invoke(index) mode = KotlinLexerMode.CODE index++ } @@ -86,6 +98,7 @@ internal fun String.kotlinLexerModeAt(offset: Int): KotlinLexerMode { KotlinLexerMode.RAW_STRING -> when { startsWith("\"\"\"", index) -> { + onLiteralEnd?.invoke(index + 2) mode = KotlinLexerMode.CODE index += 3 } @@ -108,6 +121,7 @@ internal fun String.kotlinLexerModeAt(offset: Int): KotlinLexerMode { KotlinLexerMode.CHARACTER -> when { this[index] == '\\' -> index = (index + 2).coerceAtMost(limit) this[index] == '\'' || this[index] == '\n' || this[index] == '\r' -> { + if (this[index] == '\'') onLiteralEnd?.invoke(index) mode = KotlinLexerMode.CODE index++ } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt index 625412002..3c28e0b45 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ScriptingAnalyzer.kt @@ -43,6 +43,41 @@ interface ScriptingAnalyzer { * Alt+Enter actions at the caret, such as import suggestions. */ fun intentionActions(name: String, text: String, offset: Int): List = emptyList() + + /** + * Which of [types], fully qualified class names as scripts see them, an expression typed at [offset] + * may have; used by completions that insert a whole expression. Null when [offset] is not where an + * expression starts. A language that cannot tell accepts every type. + */ + fun acceptedExpressionTypes(name: String, text: String, offset: Int, types: List): List? = types + + /** How the Kotlin [text], statements or an expression, reads on its own. Null when the language cannot parse Kotlin. */ + fun kotlinSyntax(text: String): KotlinSyntax? = null + + /** The properties of the file whose names start with [values] and [places]; those that do not compile are left out. */ + fun propertyTypes(name: String, text: String, values: String, places: String? = null): KotlinPropertyTypes = + KotlinPropertyTypes() + + /** Lets go of what is kept for the file [name], which nothing has open any more. */ + fun forget(name: String) = Unit + + /** The name at [offset] that [rename] can change, or null when it does not name a declaration of the project. */ + fun renameTarget(name: String, text: String, offset: Int): RenameTarget? = null + + /** + * What changes, through every script of the project, once the declaration named at [offset] is called [newName]: + * the declaration itself, the places that refer to it and the imports that name it. + * + * @param documents the texts of files open in the editor, read in place of what is on disk. + */ + fun rename( + name: String, + text: String, + offset: Int, + newName: String, + documents: Map, + ): List = emptyList() + fun signatureHelp(name: String, text: String, offset: Int): SignatureHelp? = null fun hover(name: String, text: String, offset: Int): HoverInfo? = null fun diagnostic(name: String, text: String): List @@ -60,6 +95,38 @@ interface ScriptingAnalyzer { fun symbolSource(qualifiedName: String): DefinitionLocation? = null } +/** + * What analysis says of named properties. + * + * @param types what each of them is, as Kotlin writes it with packages. + * @param fits by value, the places that can hold it. Null does not count: links unwrap what can be null. + */ +data class KotlinPropertyTypes( + val types: Map = emptyMap(), + val fits: Map> = emptyMap(), +) + +/** + * What a parser made of a snippet of Kotlin. + * + * @param valid whether it parsed with no errors. + * @param arguments what is between the commas, when the snippet is one call; an argument not typed yet is empty. + * @param names the names it uses from outside, without its locals and the members of other things. + * @param tokens what can be colored by the look of it, with no analysis. + */ +data class KotlinSyntax( + val valid: Boolean, + val arguments: List? = null, + val names: List = emptyList(), + val tokens: List = emptyList(), +) + +/** A token of a snippet, at the end-exclusive range [start] to [end]. */ +data class KotlinToken(val start: Int, val end: Int, val type: TokenType) + +/** A name a snippet uses, at the end-exclusive range [start] to [end]. */ +data class KotlinName(val name: String, val start: Int, val end: Int) + enum class SymbolKind { CLASS, ANNOTATION, FUNCTION, PROPERTY } /** A declaration found by [ScriptingAnalyzer.searchSymbols]; [signature] is empty for classes. */ @@ -78,6 +145,19 @@ fun interface CompletionSink { fun emit(items: List): Boolean } +/** A name [ScriptingAnalyzer.rename] can change, at the end-exclusive range [start] to [end]. */ +data class RenameTarget(val name: String, val start: Int, val end: Int) + +/** The [edits] renaming makes in the file [path], whose text was [text] when they were worked out. */ +data class FileRename(val path: String, val text: String, val edits: List) { + /** [text] with the [edits] made. */ + fun applied(): String { + val builder = StringBuilder(text) + edits.sortedByDescending { it.start }.forEach { builder.replace(it.start, it.end, it.replacement) } + return builder.toString() + } +} + data class OccurrenceRange( val start: Int, val end: Int, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryHighlighter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryHighlighter.kt deleted file mode 100644 index da09d6b8a..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryHighlighter.kt +++ /dev/null @@ -1,281 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.ide.story - -import ru.hollowhorizon.hollowengine.common.dialogue.StoryBool -import ru.hollowhorizon.hollowengine.common.dialogue.StoryNumber -import ru.hollowhorizon.hollowengine.common.dialogue.StoryString -import ru.hollowhorizon.hollowengine.common.dialogue.StoryValue -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* -import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType -import ru.hollowhorizon.hollowengine.common.scripting.ide.ui.TextSpan -import ru.hollowhorizon.hollowengine.common.utils.expressions.Ast -import ru.hollowhorizon.hollowengine.common.utils.expressions.UnaryOp - -/** - * Coloring for `.story`. It reads the same tree the compiler does, so the editor can never disagree - * with it about what a piece of text is: a speaker, a command, an argument name or an expression. - */ -internal object StoryHighlighter { - /** Built-in commands, colored as keywords; anything else is a call into the function registry. */ - val BUILTIN_COMMANDS = listOf( - "if", "else-if", "else", "while", "set", - "jump", "call", "return", - "choice", "async", "await", "cancel", "sync", - "command", - ) - - fun spans(text: String, catalog: StoryFunctionCatalog = StoryFunctionCatalog.PERMISSIVE): List { - val parsed = StoryParser.parse(text) - val spans = ArrayList() - parsed.cst.lines.forEach { line -> lineSpans(text, line, spans, catalog) } - return spans.sortedBy { it.start }.dropOverlaps() - } - - /** - * How a call is drawn: built-in commands as keywords, registered functions as functions, and a - * name nothing knows about in italics, it may still be registered on a controller, so it is not - * an error, but the difference should be visible while writing. - */ - private fun callSpan(start: Int, end: Int, name: String, catalog: StoryFunctionCatalog): TextSpan = when { - name in BUILTIN_COMMANDS -> TextSpan(start, end, TokenType.KEYWORD) - catalog.overloads(name) != null -> TextSpan(start, end, TokenType.FUNCTION) - else -> TextSpan(start, end, TokenType.FUNCTION, italic = true) - } - - private fun lineSpans(source: String, line: StoryLine, out: MutableList, catalog: StoryFunctionCatalog) { - line.commentStart?.let { start -> - out += TextSpan(line.offset + start, line.offset + line.raw.length, TokenType.COMMENT, italic = true) - } - val contentStart = line.offset + line.indent.length - val contentEnd = line.offset + (line.commentStart ?: line.raw.length) - - when (val kind = line.kind) { - is StoryLineKind.Label -> { - out += TextSpan(contentStart, kind.nameSpan.end, TokenType.TOP_LEVEL) - } - - is StoryLineKind.Dialogue -> { - val speakerExpr = kind.speakerExpr - kind.speakerSpan?.let { - out += TextSpan(it.start, it.end, if (speakerExpr == null) TokenType.CLASS else TokenType.VARIABLE) - } - speakerExpr?.let { exprSpans(it, out) } - templateSpans(source, kind.text, out, catalog) - } - - is StoryLineKind.Command -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - templateSpans(source, kind.text, out, catalog, literalType = TokenType.STRING) - } - - is StoryLineKind.If -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - exprSpans(kind.condition, out) - } - - is StoryLineKind.ElseIf -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - exprSpans(kind.condition, out) - } - - is StoryLineKind.While -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - exprSpans(kind.condition, out) - } - - is StoryLineKind.Set -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - out += TextSpan(kind.variableSpan.start, kind.variableSpan.end, TokenType.VARIABLE) - exprSpans(kind.value, out) - } - - is StoryLineKind.Jump -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - out += TextSpan(kind.target.span.start, kind.target.span.end, TokenType.STRING) - } - - is StoryLineKind.Call -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - out += TextSpan(kind.target.span.start, kind.target.span.end, TokenType.STRING) - } - - is StoryLineKind.Choice -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - // The quotes belong to the string but sit outside the template's span. - out += TextSpan(kind.text.span.start - 1, kind.text.span.start, TokenType.STRING) - out += TextSpan(kind.text.span.end, kind.text.span.end + 1, TokenType.STRING) - templateSpans(source, kind.text, out, catalog, literalType = TokenType.STRING) - kind.args.forEach { argSpans(it, out) } - } - - is StoryLineKind.FuncCall -> { - out += callSpan(contentStart, kind.functionSpan.end, kind.function, catalog) - kind.args.forEach { argSpans(it, out) } - } - - is StoryLineKind.Async -> { - out += commandKeyword(line, contentStart, contentEnd, catalog) - kind.inline?.let { call -> - out += callSpan(call.functionSpan.start, call.functionSpan.end, call.function, catalog) - call.args.forEach { argSpans(it, out) } - } - } - - is StoryLineKind.Await, is StoryLineKind.Cancel, is StoryLineKind.Sync, - is StoryLineKind.Return, is StoryLineKind.Else, - -> out += commandKeyword(line, contentStart, contentEnd, catalog) - - is StoryLineKind.Blank, is StoryLineKind.CommentOnly -> Unit - - // A line the parser could not read still shows its `@name`, so typing stays readable. - is StoryLineKind.Broken -> if (contentEnd > contentStart && line.raw[line.indent.length] == '@') { - out += commandKeyword(line, contentStart, contentEnd, catalog) - } - } - } - - private fun commandKeyword( - line: StoryLine, - contentStart: Int, - contentEnd: Int, - catalog: StoryFunctionCatalog, - ): TextSpan { - var end = contentStart + 1 // past '@' - val limit = contentEnd - line.offset - var index = line.indent.length + 1 - while (index < limit && StoryParser.isNameChar(line.raw[index])) { - index++ - end++ - } - return callSpan(contentStart, end, line.raw.substring(line.indent.length + 1, index), catalog) - } - - private fun templateSpans( - source: String, - template: TextTemplate, - out: MutableList, - catalog: StoryFunctionCatalog, - literalType: TokenType? = null, - ) { - for (part in template.parts) { - when (part) { - is TextPart.Literal -> literalSpans(source, part, out, literalType) - is TextPart.WaitInput -> out += TextSpan(part.span.start, part.span.end, TokenType.KEYWORD) - is TextPart.Interpolation -> { - out += TextSpan(part.span.start, part.span.end, TokenType.VARIABLE) - exprSpans(part.expr, out) - } - - is TextPart.InlineCall -> { - out += callSpan(part.span.start, part.span.end, part.call.function, catalog) - part.call.args.forEach { argSpans(it, out) } - } - } - } - } - - private fun literalSpans( - source: String, - part: TextPart.Literal, - out: MutableList, - literalType: TokenType?, - ) { - val raw = source.substring(part.span.start, part.span.end) - var cursor = part.span.start - FormattingTag.findAll(raw).forEach { match -> - val start = part.span.start + match.range.first - val end = part.span.start + match.range.last + 1 - if (literalType != null && cursor < start) out += TextSpan(cursor, start, literalType) - out += TextSpan(start, end, TokenType.ANNOTATION) - cursor = end - } - if (literalType != null && cursor < part.span.end) out += TextSpan(cursor, part.span.end, literalType) - } - - private fun argSpans(arg: StoryArg, out: MutableList) { - arg.nameSpan?.let { out += TextSpan(it.start, it.end, TokenType.VALUE_ARGUMENT_NAME) } - exprSpans(arg.expr, out) - } - - private fun exprSpans(expr: StoryExpression, out: MutableList) { - expr.parts.forEach { exprSpans(it, out) } - expr.ast?.let { astSpans(it, out) } - ?: expr.constant?.let { out += TextSpan(expr.span.start, expr.span.end, it.tokenType()) } - } - - /** Colors the tree the language itself parsed. */ - private fun astSpans(ast: Ast, out: MutableList) { - when (ast) { - is Ast.StringLit -> out += TextSpan(ast.span.start, ast.span.end, TokenType.STRING) - is Ast.NumberLit -> out += TextSpan(ast.span.start, ast.span.end, TokenType.NUMERIC_LITERAL) - is Ast.BoolLit -> out += TextSpan(ast.span.start, ast.span.end, TokenType.KEYWORD) - - is Ast.Name -> out += TextSpan(ast.span.start, ast.span.end, TokenType.VARIABLE) - - is Ast.Unary -> { - val operand = ast.operand - if (ast.op == UnaryOp.NEGATE && operand is Ast.NumberLit) { - out += TextSpan(ast.span.start, ast.span.end, TokenType.NUMERIC_LITERAL) - } else { - astSpans(operand, out) - } - } - - is Ast.Binary -> { - astSpans(ast.left, out) - astSpans(ast.right, out) - } - - is Ast.ListLit -> ast.items.forEach { astSpans(it, out) } - - is Ast.Index -> { - astSpans(ast.target, out) - astSpans(ast.index, out) - } - - is Ast.Access -> { - astSpans(ast.target, out) - out += TextSpan(ast.nameSpan.start, ast.nameSpan.end, TokenType.PROPERTY_IDENTIFIER) - } - - is Ast.Call -> { - ast.target?.let { astSpans(it, out) } - ast.arguments.forEach { astSpans(it, out) } - } - - is Ast.Conditional -> { - astSpans(ast.condition, out) - astSpans(ast.ifTrue, out) - astSpans(ast.ifFalse, out) - } - - is Ast.Assign -> { - astSpans(ast.target, out) - astSpans(ast.value, out) - } - - is Ast.Sequence -> ast.statements.forEach { astSpans(it, out) } - } - } - - private fun StoryValue.tokenType(): TokenType = when (this) { - is StoryString -> TokenType.STRING - is StoryNumber -> TokenType.NUMERIC_LITERAL - is StoryBool -> TokenType.KEYWORD - else -> TokenType.DEFAULT - } - - private fun List.dropOverlaps(): List { - val result = ArrayList(size) - var cursor = 0 - for (span in this) { - if (span.end <= span.start) continue - val start = maxOf(span.start, cursor) - if (start >= span.end) continue - result += if (start == span.start) span else span.copy(start = start) - cursor = span.end - } - return result - } - - private val FormattingTag = Regex("<[^>\\r\\n]+>") -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzer.kt deleted file mode 100644 index dc58a0d1c..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzer.kt +++ /dev/null @@ -1,455 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.ide.story - -import ru.hollowhorizon.hollowengine.common.dialogue.DialogueController -import ru.hollowhorizon.hollowengine.common.dialogue.StoryEngine -import ru.hollowhorizon.hollowengine.common.dialogue.StoryFunctionRegistry -import ru.hollowhorizon.hollowengine.common.dialogue.StoryString -import ru.hollowhorizon.hollowengine.common.dialogue.lang.* -import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser -import ru.hollowhorizon.hollowengine.common.scripting.ide.* -import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.utils.expressions.Ast - -/** - * IDE support for `.story`. Everything here runs through the same parser and compiler the engine - * plays stories with, so a file the editor calls clean is a file that loads, and the function names - * it offers are the ones actually registered. - */ -object StoryScriptingAnalyzer : ScriptingAnalyzer { - override fun highlight(name: String, text: String, offset: Int): List = - buildTextLines(text, StoryHighlighter.spans(text, catalog()), StoryInlays.hints(text, catalog())) - - override fun lightweightHighlightLine(name: String, line: String): TextLine = - buildTextLines(line, StoryHighlighter.spans(line, catalog()), emptyList()).firstOrNull() - ?: TextLine(emptyList(), ArrayList()) - - override fun diagnostic(name: String, text: String): List { - val result = StoryCompiler.compile(name, text, catalog()) - val offsets = LineOffsets(text) - val compilerDiagnostics = result.diagnostics.map { diagnostic -> - Diagnostic( - range = Range(offsets.position(diagnostic.span.start), offsets.position(diagnostic.span.end)), - severity = when (diagnostic.severity) { - StorySeverity.ERROR -> Severity.ERROR - StorySeverity.WARNING -> Severity.WARNING - }, - message = diagnostic.message, - ) - } - val formattingDiagnostics = StoryParser.parse(text).cst.lines.flatMap { line -> - line.kind.formattedTemplates().flatMap { template -> - val source = text.substring(template.span.start, template.span.end) - FormattedTextParser.parse(source).diagnostics.map { diagnostic -> - val start = template.span.start + diagnostic.offset - Diagnostic( - range = Range( - offsets.position(start), - offsets.position(start + diagnostic.length), - ), - severity = Severity.WARNING, - message = diagnostic.message, - ) - } - } - } - return compilerDiagnostics + formattingDiagnostics - } - - override fun completions(name: String, text: String, offset: Int, sink: CompletionSink) { - sink.emit(storyCompletions(text, offset.coerceIn(0, text.length), catalog())) - } - - override fun occurrences(name: String, text: String, offset: Int): List { - val caret = offset.coerceIn(0, text.length) - val parsed = StoryParser.parse(text) - - labelAt(parsed.cst, caret)?.let { label -> return labelOccurrences(parsed.cst, label) } - variableAt(parsed.cst, caret)?.let { variable -> return variableOccurrences(parsed.cst, variable) } - return emptyList() - } - - override fun definition(name: String, text: String, offset: Int): DefinitionLocation? { - val caret = offset.coerceIn(0, text.length) - val parsed = StoryParser.parse(text) - - for (line in parsed.cst.lines) { - val target = when (val kind = line.kind) { - is StoryLineKind.Jump -> kind.target - is StoryLineKind.Call -> kind.target - else -> null - } ?: continue - if (caret < target.span.start || caret > target.span.end) continue - - val address = target.address - if (address == null) { - val label = target.label ?: return null - return labelOffset(text, label)?.let { DefinitionLocation(name, it) } - } - return storyDefinition(address, target.label) - } - - val literal = stringLiteralAt(text, caret) ?: return null - return ResourceLocationTargets.definition(literal) - } - - private fun catalog(): StoryFunctionCatalog = - if (StoryEngine.functions.names.isEmpty()) StoryFunctionCatalog.PERMISSIVE else StoryEngine.functions - - private fun storyDefinition(address: String, label: String?): DefinitionLocation? { - val artifacts = runCatching { ScriptRegistry.artifacts(address) }.getOrNull() ?: return null - val file = artifacts.sourceFile?.takeIf { it.isFile } ?: return null - val path = ScriptRegistry.display(artifacts.id) - val offset = label?.let { labelOffset(runCatching { file.readText() }.getOrDefault(""), it) } ?: 0 - return DefinitionLocation(path, offset) - } - - private fun labelOffset(text: String, label: String): Int? = - StoryParser.parse(text).cst.lines.firstOrNull { (it.kind as? StoryLineKind.Label)?.name == label } - ?.let { (it.kind as StoryLineKind.Label).nameSpan.start } - - private fun stringLiteralAt(text: String, caret: Int): String? { - var start = caret - while (start > 0 && !text[start - 1].isWhitespace() && text[start - 1] != '"') start-- - var end = caret - while (end < text.length && !text[end].isWhitespace() && text[end] != '"') end++ - return text.substring(start, end).takeIf { it.isNotEmpty() } - } -} - -private fun StoryLineKind.formattedTemplates(): List = when (this) { - is StoryLineKind.Dialogue -> listOf(text) - is StoryLineKind.Choice -> listOf(text) - else -> emptyList() -} - -/** The label named at [caret], whether that is its declaration or a jump/call that reaches it. */ -private fun labelAt(cst: StoryFileCst, caret: Int): String? { - for (line in cst.lines) { - when (val kind = line.kind) { - is StoryLineKind.Label -> if (caret in kind.nameSpan.start..kind.nameSpan.end) return kind.name - - is StoryLineKind.Jump -> targetLabelAt(kind.target, caret)?.let { return it } - is StoryLineKind.Call -> targetLabelAt(kind.target, caret)?.let { return it } - else -> Unit - } - } - return null -} - -/** Only local targets take part: `file.story#Label` names a label of another file. */ -private fun targetLabelAt(target: StoryRef, caret: Int): String? { - if (target.address != null || target.label == null) return null - return target.label.takeIf { caret in target.span.start..target.span.end } -} - -private fun labelOccurrences(cst: StoryFileCst, label: String): List { - val ranges = ArrayList() - for (line in cst.lines) { - when (val kind = line.kind) { - is StoryLineKind.Label -> if (kind.name == label) ranges += OccurrenceRange( - kind.nameSpan.start, - kind.nameSpan.end - ) - - is StoryLineKind.Jump -> targetRange(kind.target, label)?.let { ranges += it } - is StoryLineKind.Call -> targetRange(kind.target, label)?.let { ranges += it } - else -> Unit - } - } - return ranges.sortedBy { it.start } -} - -private fun targetRange(target: StoryRef, label: String): OccurrenceRange? { - if (target.address != null || target.label != label) return null - // Highlight the label part only, leaving the `#` alone. - return OccurrenceRange(target.span.end - label.length, target.span.end) -} - -/** The variable named at [caret], in a `@set`, an expression or a `{…}` interpolation. */ -private fun variableAt(cst: StoryFileCst, caret: Int): String? { - for (line in cst.lines) { - val kind = line.kind - if (kind is StoryLineKind.Set && caret in kind.variableSpan.start..kind.variableSpan.end) { - return kind.variable - } - for (reference in variableReferences(kind)) { - if (caret in reference.span.start..reference.span.end) return reference.name - } - } - return null -} - -private fun variableOccurrences(cst: StoryFileCst, variable: String): List { - val ranges = ArrayList() - for (line in cst.lines) { - val kind = line.kind - if (kind is StoryLineKind.Set && kind.variable == variable) { - ranges += OccurrenceRange(kind.variableSpan.start, kind.variableSpan.end) - } - variableReferences(kind).filter { it.name == variable } - .forEach { ranges += OccurrenceRange(it.span.start, it.span.end) } - } - return ranges.sortedBy { it.start } -} - -private fun variableReferences(kind: StoryLineKind): List { - val found = ArrayList() - fun collect(expr: StoryExpression) { - found += expr.names() - } - - fun collect(template: TextTemplate) { - template.parts.forEach { part -> - when (part) { - is TextPart.Interpolation -> collect(part.expr) - is TextPart.InlineCall -> part.call.args.forEach { collect(it.expr) } - is TextPart.Literal, is TextPart.WaitInput -> Unit - } - } - } - - when (kind) { - is StoryLineKind.If -> collect(kind.condition) - is StoryLineKind.ElseIf -> collect(kind.condition) - is StoryLineKind.While -> collect(kind.condition) - is StoryLineKind.Set -> collect(kind.value) - is StoryLineKind.Dialogue -> { - kind.speakerExpr?.let { collect(it) } - collect(kind.text) - } - - is StoryLineKind.Command -> collect(kind.text) - is StoryLineKind.Choice -> { - collect(kind.text) - kind.args.forEach { collect(it.expr) } - } - - is StoryLineKind.FuncCall -> kind.args.forEach { collect(it.expr) } - is StoryLineKind.Async -> kind.inline?.args?.forEach { collect(it.expr) } - else -> Unit - } - return found -} - -internal class LineOffsets(text: String) { - private val starts = buildList { - add(0) - text.forEachIndexed { index, char -> if (char == '\n') add(index + 1) } - } - - fun position(offset: Int): Position { - val clamped = offset.coerceIn(0, Int.MAX_VALUE) - var low = 0 - var high = starts.size - 1 - while (low < high) { - val mid = (low + high + 1) / 2 - if (starts[mid] <= clamped) low = mid else high = mid - 1 - } - return Position(low, clamped - starts[low]) - } -} - -internal fun storyCompletions( - text: String, - caret: Int, - catalog: StoryFunctionCatalog, -): List { - val lineStart = text.lastIndexOf('\n', (caret - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } - val line = text.substring(lineStart, caret) - - return when (val context = storyCompletionContext(line, line.length)) { - is StoryCompletionContext.None -> emptyList() - - is StoryCompletionContext.Command -> (StoryHighlighter.BUILTIN_COMMANDS + registeredNames(catalog)).distinct() - .filter { completionMatches(context.typed, it) }.flatMap { command -> commandCompletions(command, catalog) } - - is StoryCompletionContext.Label -> labelCompletions(text, context.typed) - - is StoryCompletionContext.Expression -> if (context.member) memberCompletions(context.typed) else variableCompletions( - text, - context.typed - ) - - is StoryCompletionContext.Argument -> if (context.command == "set") { - variableCompletions(text, context.typed) - } else { - val values = valueCompletions(text, context, catalog) - if (context.parameter != null) values - else values + parameterNameCompletions(context.command, context.typed, catalog) - } - } -} - -private fun registeredNames(catalog: StoryFunctionCatalog): List = - (catalog as? StoryFunctionRegistry)?.names?.sorted().orEmpty() - -private fun labelCompletions(text: String, typed: String): List = - labelsOf(text).filter { completionMatches(typed, it) }.map { label -> - declarationCompletionItem { - show = "#$label" - insert = "#$label" - name = label - tag = CompletionItemTag.CLASS - tail = "label" - wordChars = "#" - } - } - -private fun variableCompletions(text: String, typed: String): List = - variablesOf(text).filter { it != typed && completionMatches(typed, it) }.map { variable -> - declarationCompletionItem { - show = variable - name = variable - tag = CompletionItemTag.LOCAL_VARIABLE - tail = "variable" - } - } - -private fun memberCompletions(typed: String): List = - StoryDeclarations.types["value"]?.members?.allFields.orEmpty().flatMap { it.names }.distinct() - .filter { completionMatches(typed, it) }.map { member -> - declarationCompletionItem { - show = member - name = member - tag = CompletionItemTag.PROPERTY - tail = "member" - } - } - -private fun parameterNameCompletions( - command: String, - typed: String, - catalog: StoryFunctionCatalog, -): List = - catalog.overloads(command).orEmpty().asSequence().flatMap(StoryFunctionSignature::params).map { it.name }.distinct() - .filter { completionMatches(typed, it) }.map { parameter -> - declarationCompletionItem { - show = "$parameter=" - insert = "$parameter=" - name = parameter - tag = CompletionItemTag.PROPERTY - tail = "parameter" - wordChars = "-" - } - }.toList() - -private fun valueCompletions( - text: String, - context: StoryCompletionContext.Argument, - catalog: StoryFunctionCatalog, -): List { - val params = catalog.overloads(context.command).orEmpty().flatMap { it.params } - val parameter = when (val name = context.parameter) { - null -> params.getOrNull(context.positional) - else -> params.firstOrNull { it.name == name } - } ?: return emptyList() - - val values = when { - parameter.suggestions.isNotEmpty() -> parameter.suggestions - parameter.type == StoryType.ACTOR -> actorsOf(text) - parameter.type == StoryType.BOOL -> listOf("true", "false") - else -> return emptyList() - } - - return values.filter { completionMatches(context.typed, it) }.map { value -> - declarationCompletionItem { - show = value - name = value - tag = if (parameter.type == StoryType.ACTOR) CompletionItemTag.CLASS else CompletionItemTag.PROPERTY - tail = parameter.name - wordChars = "-:" - } - } -} - -private fun commandCompletions(command: String, catalog: StoryFunctionCatalog): List { - val builtin = command in StoryHighlighter.BUILTIN_COMMANDS - if (builtin) return listOf(commandCompletion(command, BUILTIN_USAGE[command].orEmpty(), builtin = true)) - - val overloads = catalog.overloads(command).orEmpty() - if (overloads.isEmpty()) return listOf(commandCompletion(command, "", builtin = false)) - return overloads.map { signature -> - commandCompletion(command, signature.params.joinToString(" ") { it.display() }, builtin = false) - } -} - -private fun commandCompletion(command: String, params: String, builtin: Boolean): CompletionItem = - declarationCompletionItem { - show = "@$command" - insert = "@$command " - name = command - tag = if (builtin) CompletionItemTag.KEYWORD else CompletionItemTag.FUNCTION - wordChars = "@-" - middle = if (params.isEmpty()) "" else " $params" - tail = if (builtin) "built-in" else "function" - } - -private val BUILTIN_USAGE = mapOf( - "if" to "condition", - "else-if" to "condition", - "else" to "", - "while" to "condition", - "set" to "variable = value", - "jump" to "#label | file.story#label", - "call" to "#label | file.story#label", - "return" to "", - "choice" to "\"text\" [id=… if=…]", - "async" to "[name=…] [command]", - "await" to "[track names]", - "cancel" to "track name", - "sync" to "", - "command" to "vanilla command", -) - -private fun labelsOf(text: String): List = - StoryParser.parse(text).cst.lines.mapNotNull { (it.kind as? StoryLineKind.Label)?.name } - -private fun actorsOf(text: String): List { - val speakers = - StoryParser.parse(text).cst.lines.mapNotNull { (it.kind as? StoryLineKind.Dialogue)?.speaker }.distinct() - return (listOf(DialogueController.PLAYER_ACTOR) + speakers).distinct() -} - -private fun variablesOf(text: String): List { - val names = LinkedHashSet() - for (line in StoryParser.parse(text).cst.lines) { - val kind = line.kind - if (kind is StoryLineKind.Set) names += kind.variable - variableReferences(kind).forEach { names += it.name } - } - return names.toList() -} - -internal object StoryInlays { - fun hints(text: String, catalog: StoryFunctionCatalog): List { - val parsed = StoryParser.parse(text) - val hints = ArrayList() - for (line in parsed.cst.lines) { - val call = when (val kind = line.kind) { - is StoryLineKind.FuncCall -> kind - is StoryLineKind.Async -> kind.inline ?: continue - else -> continue - } - val signature = catalog.overloads(call.function).orEmpty() - .firstOrNull { it.params.size >= call.args.count { arg -> arg.name == null } } - - call.args.forEachIndexed { index, arg -> - if (arg.name == null) { - signature?.params?.getOrNull(index)?.let { param -> - hints += InlayHint(arg.span.start, "${param.name}=", listOf(InlayTags.PARAMETER)) - } - } - val literal = arg.expr.constant as? StoryString ?: return@forEachIndexed - if (!ResourceLocationTargets.looksLikeLocation(literal.value)) return@forEachIndexed - val target = ResourceLocationTargets.targetOf(literal.value) ?: return@forEachIndexed - hints += InlayHint( - index = arg.span.end, - content = listOf(InlayContent.Icon(InlayIcons.OPEN_RESOURCE)), - tags = listOf(InlayTags.ACTION, target.tag), - action = InlayAction.OpenResource(literal.value), - ) - } - } - return hints - } -} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDiagnostics.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDiagnostics.kt index 1d092ceec..8edb60b21 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDiagnostics.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDiagnostics.kt @@ -9,6 +9,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic import ru.hollowhorizon.hollowengine.common.scripting.ide.Position import ru.hollowhorizon.hollowengine.common.scripting.ide.Range import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity +import ru.hollowhorizon.hollowengine.common.utils.Spelling /** * Diagnostics for a stylesheet: syntax errors reported on the token they are about, plus @@ -134,8 +135,8 @@ private fun keyframeDiagnostics( } private fun unknownPropertyMessage(property: String): String { - val suggestion = HssSchema.allNames.minByOrNull { editDistance(property.lowercase(), it) } - ?.takeIf { editDistance(property.lowercase(), it) <= property.length / 2 + 1 } + val suggestion = HssSchema.allNames.minByOrNull { Spelling.distance(property.lowercase(), it) } + ?.takeIf { Spelling.distance(property.lowercase(), it) <= property.length / 2 + 1 } return if (suggestion == null) { "Unknown property '$property'" } else { @@ -143,24 +144,6 @@ private fun unknownPropertyMessage(property: String): String { } } -/** Small Levenshtein distance, used only to suggest a property name on a typo. */ -private fun editDistance(left: String, right: String): Int { - if (left == right) return 0 - var previous = IntArray(right.length + 1) { it } - var current = IntArray(right.length + 1) - for (i in 1..left.length) { - current[0] = i - for (j in 1..right.length) { - val substitution = previous[j - 1] + if (left[i - 1] == right[j - 1]) 0 else 1 - current[j] = minOf(current[j - 1] + 1, previous[j] + 1, substitution) - } - val swap = previous - previous = current - current = swap - } - return previous[right.length] -} - /** Offset-to-position mapping computed once per diagnostics pass. */ private class LineOffsets(text: String) { private val starts = buildList { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt index 830515f9c..18fccde08 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/mixins/MixinScriptStage.kt @@ -72,7 +72,8 @@ internal object MixinScriptStage { private class Addons { val store = HollowAddonArtifactStore(HollowAddonManager.cacheDirectory) private val directories = HollowAddonManager.defaultSources() - private val disabled = HollowAddonActivationStore(directories.first().resolve(".disabled-addons")).load() + private val disabled = + HollowAddonActivationStore(HollowAddonManager.addonsDirectory.resolve(".disabled-addons")).load() private val jars = directories.mapIndexed { priority, directory -> priority to HollowAddonProbe.listAddonJars(directory) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt index 02030c331..b0a0fd2a8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/EntityNodeRuntime.kt @@ -11,7 +11,6 @@ import ru.hollowhorizon.hollowengine.common.attachments.editor.ScriptEditorInfo import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext /** * Entry point for the nodes attached to entities via `@file:Attach`. @@ -22,8 +21,14 @@ import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext * so cancelling that scope tears its nodes down. */ object EntityNodeRuntime { - fun attach(entity: Entity, path: String, tag: CompoundTag? = null, context: StateContext? = null): Boolean = - AttachmentRegistry.attachments(entity).nodes.attach(path, tag, context) + /** + * Attaches the node [path] to [entity]. + * + * @param tag the data its `onLoad` handlers receive. + * @param entry what to start with, see [ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent.entry]. + */ + fun attach(entity: Entity, path: String, tag: CompoundTag? = null, entry: String? = null): Boolean = + AttachmentRegistry.attachments(entity).nodes.attach(path, tag, entry) fun detach(entity: Entity, path: String): Boolean = managerOrNull(entity)?.detach(path) ?: false @@ -70,56 +75,55 @@ class EntityNodeManager(private val entity: Entity) { /** Attached nodes whose namespace is currently unavailable. See [NodeManager] for the rationale. */ private val dormant = mutableMapOf() - fun attach(path: String, tag: CompoundTag?, context: StateContext?): Boolean { + fun attach(path: String, tag: CompoundTag?, entry: String?): Boolean { val canonicalPath = canonicalNodePath(path) if (nodes.containsKey(canonicalPath) || lifecycle.isPending(canonicalPath)) return false - if (tag == null && context == null) { + if (tag == null && entry == null) { dormant[canonicalPath]?.let { return attachSaved(canonicalPath, it) } } - return start(canonicalPath, tag, context) + return start(canonicalPath, nodeSave(tag), entry) } /** Attaches a node from the state [serialize] wrote for it. */ - private fun attachSaved(canonicalPath: String, nodeTag: CompoundTag, compiled: CompiledScript? = null): Boolean = start( - canonicalPath, - nodeTag.getCompound("extras"), - (nodeTag.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) }, - compiled, - ) - - private fun start(canonicalPath: String, tag: CompoundTag?, context: StateContext?, compiled: CompiledScript? = null): Boolean { - dormant[canonicalPath] = CompoundTag().apply { - tag?.let { put("extras", it.copy()) } - context?.let { put("states", it.serialize().copy()) } - } + private fun attachSaved(canonicalPath: String, nodeTag: CompoundTag, compiled: CompiledScript? = null): Boolean = + start(canonicalPath, nodeTag, entry = null, compiled) + + private fun start(canonicalPath: String, saved: CompoundTag, entry: String?, compiled: CompiledScript? = null): Boolean { + dormant[canonicalPath] = saved.copy() var accepted = true lifecycle.start(canonicalPath) { - accepted = startNow(canonicalPath, tag, context, compiled) + accepted = startNow(canonicalPath, saved, entry, compiled) } return accepted } private fun startNow( canonicalPath: String, - tag: CompoundTag?, - context: StateContext?, + saved: CompoundTag, + entry: String?, compiled: CompiledScript?, ): Boolean { val server = entity.server ?: return false - val (script, executor) = buildNode( + val script = buildNode( host = NodeHost.OfEntity(entity), parentScope = entity.coroutineScope, path = canonicalPath, - tag = tag, + saved = saved, + entry = entry, receivers = listOf(server, entity), compiled = compiled, - ) ?: return false + ) + if (script == null) { + if (dormant.remove(canonicalPath) != null) { + HollowEngine.LOGGER.error("Node '{}' is removed from {}: it could not start", canonicalPath, entity.uuid) + } + return false + } dormant.remove(canonicalPath) - nodes[canonicalPath] = RunningNode(script, executor, context) - context?.let { executor.start(it) } + nodes[canonicalPath] = RunningNode(script) return true } @@ -188,9 +192,8 @@ class EntityNodeManager(private val entity: Entity) { } /** - * A node that cannot be attached right now stays dormant rather than being dropped: [serialize] - * writes dormant nodes back out, so a script that was briefly unavailable costs the entity a node - * until something attaches it again, not forever at the next world save. + * A node whose namespace is not installed right now stays dormant: [serialize] writes it back out, so + * turning the addon on again brings it back. A node that is installed but cannot start is removed. */ fun deserialize(tag: CompoundTag) { tag.allKeys.forEach { path -> @@ -203,13 +206,8 @@ class EntityNodeManager(private val entity: Entity) { .onFailure { HollowEngine.LOGGER.error("Error while deserializing entity node '$path'", it) } .getOrDefault(false) - if (attached) return@forEach + if (attached || available) return@forEach dormant[canonicalPath] = nodeTag - // A missing namespace is an ordinary wait. Anything else means the node is not running on an - // entity that is saved with it, which is worth hearing about the first time it happens. - if (available) { - HollowEngine.LOGGER.warn("Entity node '{}' of {} stays dormant: nothing attached it", canonicalPath, entity.uuid) - } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt index 0a22827a5..affd3a970 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeAttachTargets.kt @@ -37,7 +37,7 @@ object NodeAttachTargets { fun of(id: ScriptId): AttachTarget { val file = ScriptRegistry.artifacts(id)?.sourceFile ?: return AttachTarget.Unknown val stamp = runCatching { file.lastModified() }.getOrDefault(0L) - val classLoader = ScriptRegistry.source(id.namespace)?.classLoader ?: NodeAttachTargets::class.java.classLoader + val classLoader = ScriptRegistry.classLoader(id.namespace) ?: NodeAttachTargets::class.java.classLoader val key = file.path cache[key]?.takeIf { it.stamp == stamp && it.classLoader === classLoader }?.let { return it.target } val target = read(file, id.fileName, classLoader) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt deleted file mode 100644 index ccdaaa397..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeLoader.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ru.hollowhorizon.hollowengine.common.scripting.nodes - -import ru.hollowhorizon.hollowengine.common.scripting.annotations.State -import ru.hollowhorizon.hollowengine.common.scripting.state.StateExecutor -import ru.hollowhorizon.hollowengine.common.utils.LambdaGenerator -import ru.hollowhorizon.hollowengine.common.utils.UnsafeTools -import java.lang.invoke.MethodHandles -import java.lang.reflect.Method - -fun NodeScript.prepareExecutor(executor: StateExecutor) { - val lookup = MethodHandles.privateLookupIn(javaClass, UnsafeTools.lookup) - - javaClass.nodeStateFunctions().forEach { (name, method) -> - executor.addState(name, LambdaGenerator.createSuspendRunnable(lookup, method, this)) - } -} - -internal fun Class<*>.nodeStateFunctions(): Map = declaredMethods.mapNotNull { method -> - val annotation = method.getAnnotation(State::class.java) ?: return@mapNotNull null - (annotation.name.takeIf(String::isNotBlank) ?: method.name) to method -}.toMap() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt index f401fae0e..c3b0d6bb6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeScript.kt @@ -2,6 +2,7 @@ package ru.hollowhorizon.hollowengine.common.scripting.nodes import kotlinx.coroutines.CoroutineScope import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag import net.minecraft.server.MinecraftServer import kotlin.coroutines.CoroutineContext @@ -24,8 +25,27 @@ abstract class NodeScript( internal val onSaveHandlers = mutableListOf<(SerializationContext) -> Unit>() internal val onLoadHandlers = mutableListOf<(SerializationContext) -> Unit>() internal val onStartHandlers = mutableListOf Unit>() + internal val sections = linkedMapOf() val editor: NodeEditor = NodeEditor(this) + + /** + * Keeps [section] with this node under [key], beside the script's own `onSave` data. Register it from + * [ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent]: the saved value is handed back + * right after, before `onLoad`. + */ + fun section(key: String, section: NodeSection) { + require(sections.putIfAbsent(key, section) == null) { "Node '$path' already has a section '$key'" } + } +} + +/** Data an addon stores with a node, see [NodeScript.section]. */ +interface NodeSection { + /** What to write into the node save, or null to write nothing. */ + fun save(): Tag? + + /** Restores what [save] wrote the last time the node was saved. */ + fun load(tag: Tag) } data class SerializationContext( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt index 1d747c41c..0c9f3f9ab 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/nodes/NodeSystem.kt @@ -5,11 +5,11 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.server.MinecraftServer import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry -import ru.hollowhorizon.hollowengine.common.scripting.state.StateContext -import ru.hollowhorizon.hollowengine.common.scripting.state.StateExecutor import kotlin.script.experimental.api.ScriptEvaluationConfiguration import kotlin.script.experimental.api.constructorArgs import kotlin.script.experimental.api.implicitReceivers @@ -62,19 +62,27 @@ class NodeManager(val server: MinecraftServer) { HollowEngine.LOGGER.info("Node '{}' is kept dormant: its namespace is not installed", key) return@runCatching } - val extras = nodeTag.getCompound("extras") - val context = (nodeTag.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) } - server.addNode(key, extras, context) + server.startNode(key, nodeTag, entry = null, compiled = null) }.onFailure { HollowEngine.LOGGER.error("Error while deserializing node '$key'", it) } } } - internal fun register(script: NodeScript, executor: StateExecutor, context: StateContext?) { + internal fun register(script: NodeScript) { dormant.remove(script.path) - nodes[script.path] = RunningNode(script, executor, context) - context?.let { executor.start(it) } + nodes[script.path] = RunningNode(script) + notifyExecutionChanged() + } + + /** + * Forgets the saved node [path], which could not start: its script is gone or broken. Kept, it would fail + * at every start and stay in the world save with nothing to remove it. + */ + internal fun discard(path: String) { + val canonicalPath = canonicalNodePath(path) + if (dormant.remove(canonicalPath) == null) return + HollowEngine.LOGGER.error("Server node '{}' is removed from the world: it could not start", canonicalPath) notifyExecutionChanged() } @@ -145,12 +153,7 @@ class NodeManager(val server: MinecraftServer) { } private fun startSaved(path: String, saved: CompoundTag, compiled: CompiledScript) { - server.addNode( - path, - saved.getCompound("extras"), - (saved.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) }, - compiled, - ) + server.startNode(path, saved, entry = null, compiled = compiled) } /** Stops the nodes of [namespace] without losing their state. */ @@ -173,8 +176,7 @@ class NodeManager(val server: MinecraftServer) { if (path in nodes || lifecycle.isPending(path)) return@forEach dormant.remove(path) runCatching { - val context = (nodeTag.get("states") as? CompoundTag)?.let { StateContext.deserialize(it) } - server.addNode(path, nodeTag.getCompound("extras"), context) + server.startNode(path, nodeTag, entry = null, compiled = null) }.onFailure { HollowEngine.LOGGER.error("Error while resuming node '$path'", it) } } } @@ -182,43 +184,51 @@ class NodeManager(val server: MinecraftServer) { private fun isAvailable(path: String): Boolean = ScriptRegistry.source(ScriptRegistry.parse(path).namespace) != null } -internal class RunningNode( - val script: NodeScript, - val executor: StateExecutor, - val context: StateContext?, -) { +internal class RunningNode(val script: NodeScript) { /** The tag this node would be written to the world save as. */ fun persist(server: MinecraftServer): CompoundTag { val serialization = SerializationContext(server, CompoundTag()) script.onSaveHandlers.forEach { it(serialization) } + val sections = CompoundTag() + script.sections.forEach { (key, section) -> section.save()?.let { sections.put(key, it) } } return CompoundTag().apply { - put("extras", serialization.tag) - context?.let { put("states", it.serialize()) } + put(EXTRAS, serialization.tag) + if (!sections.isEmpty) put(SECTIONS, sections) }.copy() } } +/** A node save written by [RunningNode.persist] that holds only the script's own [extras]. */ +internal fun nodeSave(extras: CompoundTag?): CompoundTag = + CompoundTag().apply { extras?.let { put(EXTRAS, it.copy()) } } + /** * Compiles [path], binds it to a fresh child scope of [host]'s scope, runs the script body (which - * registers its handlers), then replays `onLoad` -> `onStart` -> `@State` states. + * registers its handlers), announces it with [ScriptStartedEvent], then replays the sections and `onLoad` + * of [saved] and launches `onStart`. * * The bound [NodeScript] is returned so the caller can register it with the owning manager; `null` is * returned when compilation is unavailable or fails. + * + * @param saved what [RunningNode.persist] wrote for the node. + * @param entry what to start with, passed on to [ScriptStartedEvent]. */ internal fun buildNode( host: NodeHost, parentScope: CoroutineScope, path: String, - tag: CompoundTag?, + saved: CompoundTag, + entry: String?, receivers: List, compiled: CompiledScript? = null, -): Pair? { +): NodeScript? { val id = ScriptRegistry.parse(path) // Nodes are keyed by their path in the world save, so every spelling of one script has to settle on // the same string before anything is registered. val canonicalPath = ScriptRegistry.display(id) val nodeScope = CoroutineScope(parentScope.coroutineContext + SupervisorJob(parentScope.coroutineContext.job)) - val binding = NodeBinding(host, nodeScope, tag) + val extras = saved.takeIf { it.contains(EXTRAS) }?.getCompound(EXTRAS) + val binding = NodeBinding(host, nodeScope, extras) val validate: (CompiledScript) -> Unit = { program -> receiverMismatch(program.implicitReceiverCount, host, receivers)?.let { error(it) } @@ -231,22 +241,26 @@ internal fun buildNode( val script = ScriptLoader.executeCompiled(id, validate, compiled, configure).onFailure { HollowEngine.LOGGER.error("Error while loading $canonicalPath", it) nodeScope.cancel() + ScriptFailedEvent.post(ScriptFailedEvent(id)) }.getOrNull() ?: return null return runCatching { - val executor = StateExecutor(nodeScope) - script.prepareExecutor(executor) + ScriptStartedEvent.post(ScriptStartedEvent(id, script, nodeScope, entry)) + + val sections = saved.getCompound(SECTIONS) + script.sections.forEach { (key, section) -> sections.get(key)?.let(section::load) } - tag?.let { extras -> - val context = SerializationContext(host.server, extras) - script.onLoadHandlers.forEach { it(context) } + extras?.let { + val context = SerializationContext(host.server, it) + script.onLoadHandlers.forEach { load -> load(context) } } script.onStartHandlers.forEach { start -> nodeScope.launch { start() } } - script to executor + script }.onFailure { HollowEngine.LOGGER.error("Error while initializing $canonicalPath", it) nodeScope.cancel() + ScriptFailedEvent.post(ScriptFailedEvent(id)) }.getOrNull() } @@ -285,33 +299,39 @@ private fun NodeHost.describe(): String = when (this) { */ fun canonicalNodePath(path: String): String = ScriptRegistry.display(ScriptRegistry.parse(path)) -fun MinecraftServer.addNode(path: String, tag: CompoundTag? = null, context: StateContext? = null) = - addNode(path, tag, context, null) +/** + * Starts the server node [path], restarting it if it already runs. + * + * @param tag the data its `onLoad` handlers receive. + * @param entry what to start with, see [ScriptStartedEvent.entry]. + */ +fun MinecraftServer.addNode(path: String, tag: CompoundTag? = null, entry: String? = null) = + startNode(path, nodeSave(tag), entry, null) -internal fun MinecraftServer.addNode( +internal fun MinecraftServer.startNode( path: String, - tag: CompoundTag?, - context: StateContext?, + saved: CompoundTag, + entry: String?, compiled: CompiledScript?, ) { - val saved = CompoundTag().apply { - tag?.let { put("extras", it.copy()) } - context?.let { put("states", it.serialize().copy()) } - } - runtimeContext.nodes.startAfterStop(path, saved) { - val (script, executor) = buildNode( + runtimeContext.nodes.startAfterStop(path, saved.copy()) { + val script = buildNode( host = NodeHost.Server(this), parentScope = runtimeContext.scope, path = path, - tag = tag, + saved = saved, + entry = entry, receivers = listOf(this), compiled = compiled, - ) ?: return@startAfterStop + ) ?: return@startAfterStop runtimeContext.nodes.discard(path) - runtimeContext.nodes.register(script, executor, context) + runtimeContext.nodes.register(script) } } fun MinecraftServer.removeNode(path: String) { runtimeContext.nodes.removeNode(path) } + +private const val EXTRAS = "extras" +private const val SECTIONS = "sections" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt index a96baf8f0..a54261481 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ReloadScriptRunner.kt @@ -7,6 +7,8 @@ import ru.hollowhorizon.hollowengine.common.events.LogicalSide import ru.hollowhorizon.hollowengine.common.scripting.RELOAD_SCRIPT_EXTENSION import ru.hollowhorizon.hollowengine.common.scripting.ScriptEventHandlers import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader +import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent +import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import kotlin.script.experimental.api.constructorArgs @@ -51,9 +53,11 @@ internal class ReloadScriptRunner(private val side: LogicalSide) { implicitReceivers(receiver) }.onSuccess { script -> ScriptEventHandlers.subscribe(id, script, scriptScope) + ScriptStartedEvent.post(ScriptStartedEvent(id, script, scriptScope)) }.onFailure { error -> scriptScope.cancel() HollowEngine.LOGGER.error("Failed to execute reload script: {}", ScriptRegistry.display(id), error) + if (side == LogicalSide.SERVER) ScriptFailedEvent.post(ScriptFailedEvent(id)) } } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt index 2ea142644..144937132 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ScriptSides.kt @@ -14,7 +14,7 @@ object ScriptSides { artifacts.sourceFile?.let { source -> return declaresClientSide(source.readText()) } val jar = artifacts.precompiled ?: return false return runCatching { - jar.loadKotlinCompiledScriptFromJar(ScriptRegistry.source(id.namespace)?.classLoader).isClientSide + jar.loadKotlinCompiledScriptFromJar(ScriptRegistry.classLoader(id.namespace)).isClientSide }.onFailure { error -> HollowEngine.LOGGER.error("Cannot tell the side of '{}' from its compiled artifact", ScriptRegistry.display(id), error) }.getOrDefault(false) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt index bbcbc38e7..0bc674f3d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScripts.kt @@ -6,7 +6,6 @@ import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.api.ReloadListener import ru.hollowhorizon.hollowengine.common.compat.util.currentRecipeManagerOrNull import ru.hollowhorizon.hollowengine.common.coroutines.RuntimeDispatcherState -import ru.hollowhorizon.hollowengine.common.dialogue.StoryEngine import ru.hollowhorizon.hollowengine.common.events.LogicalSide import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent @@ -26,7 +25,7 @@ object ServerReloadScripts : ResourceManagerReloadListener { } override fun onResourceManagerReload(resourceManager: ResourceManager) { - StoryEngine.prepareReload() + ServerReloadScriptsEvent.Pre.post(ServerReloadScriptsEvent.Pre()) val recipeManager = currentRecipeManagerOrNull() if (recipeManager == null) { HollowEngine.LOGGER.warn("Skipping reload scripts: RecipeManager is not initialized yet") @@ -34,7 +33,7 @@ object ServerReloadScripts : ResourceManagerReloadListener { val dispatcher = RuntimeDispatcherState.loadingServerDispatcher() runner.run(dispatcher, resourceManager, ServerReloadContext(recipeManager)) } - StoryEngine.completeReload() + ServerReloadScriptsEvent.Post.post(ServerReloadScriptsEvent.Post()) pendingCommands?.let { event -> pendingCommands = null diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt new file mode 100644 index 000000000..aad56a44f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/reload/ServerReloadScriptsEvent.kt @@ -0,0 +1,20 @@ +package ru.hollowhorizon.hollowengine.common.scripting.reload + +import ru.hollowhorizon.hollowengine.common.events.ServerEvent +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler + +/** + * Brackets the server reload scripts of a datapack load, so a registry the scripts fill can be + * reset before they run and published after. + */ +sealed class ServerReloadScriptsEvent : ServerEvent { + /** The scripts are about to run. */ + class Pre : ServerReloadScriptsEvent() { + companion object : EventHandler
()
+    }
+
+    /** The scripts have run, even if some of them failed or were skipped. */
+    class Post : ServerReloadScriptsEvent() {
+        companion object : EventHandler()
+    }
+}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt
index e41bfda26..a317465f1 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptRegistry.kt
@@ -5,6 +5,7 @@ import ru.hollowhorizon.hollowengine.HollowEngineBuild
 import ru.hollowhorizon.hollowengine.common.addons.HollowAddonClassLoader
 import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry.parse
 import java.io.File
+import java.net.URI
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.CopyOnWriteArrayList
 
@@ -114,11 +115,23 @@ object ScriptRegistry {
     fun artifacts(raw: String): ScriptArtifacts? = artifacts(parse(raw))
 
     /**
-     * The script a materialised source file belongs to. Addon sources are extracted into the cache
+     * The script a materialized source file belongs to. Addon sources are extracted into the cache
      * directory, so the path alone does not say which namespace owns them.
      */
     fun idOf(file: File): ScriptId? = filesToIds[file.canonicalPath]
 
+    /** Makes [file], generated from the script [id], belong to it. */
+    internal fun bind(file: File, id: ScriptId) {
+        filesToIds[file.canonicalPath] = id
+    }
+
+    /** Physical paths, file URIs and portable namespace IDs used by Kotlin script metadata. */
+    fun idOfLocation(location: String): ScriptId? {
+        val file = runCatching { if (location.startsWith("file:")) File(URI(location)) else File(location) }.getOrNull()
+        file?.let { runCatching { idOf(it) }.getOrNull() }?.let { return it }
+        return runCatching { parse(location) }.getOrNull()?.takeIf { artifacts(it) != null }
+    }
+
     /**
      * Classpath the scripts of [namespace] compile against: its own, plus that of every namespace it
      * declares in `dependsOn`, so a project can call the classes of the addons it depends on.
@@ -133,11 +146,21 @@ object ScriptRegistry {
      */
     fun classLoader(namespace: String): ClassLoader? {
         val source = source(namespace) ?: return null
-        val dependencies = closure(namespace).filter { it !== source }.map(ScriptSource::classLoader).distinct()
-        if (dependencies.isEmpty()) return source.classLoader
         return synchronized(lock) {
             dependencyLoaders.getOrPut(namespace) {
-                HollowAddonClassLoader(emptyArray(), source.classLoader, dependencies)
+                val closure = closure(namespace)
+                closure.forEach { dependent ->
+                    dependent.dependencies.filter { source(it) == null }.forEach { missing ->
+                        HollowEngine.LOGGER.warn(
+                            "Scripts of '{}' depend on '{}', which is not loaded: its classes are not available to them",
+                            dependent.namespace,
+                            missing,
+                        )
+                    }
+                }
+                val dependencies = closure.filter { it !== source }.map(ScriptSource::classLoader).distinct()
+                if (dependencies.isEmpty()) source.classLoader
+                else HollowAddonClassLoader(emptyArray(), source.classLoader, dependencies)
             }
         }
     }
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptTranslators.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptTranslators.kt
new file mode 100644
index 000000000..f8c13a892
--- /dev/null
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/source/ScriptTranslators.kt
@@ -0,0 +1,49 @@
+package ru.hollowhorizon.hollowengine.common.scripting.source
+
+import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache
+import java.io.File
+
+/** Turns the source of a script that is not written in Kotlin into Kotlin. */
+fun interface ScriptTranslator {
+    /** The Kotlin of the script [id], whose source is [source]; throws when there is none to give. */
+    fun translate(id: ScriptId, source: File): String
+}
+
+/**
+ * The sources an addon teaches the engine to compile, by the extension of their files. What a translator gives is
+ * compiled as an ordinary script but stays the script [id]: what it reports and saves names the source.
+ */
+object ScriptTranslators {
+    private val translators = HashMap()
+
+    fun register(extension: String, translator: ScriptTranslator) {
+        translators[extension] = translator
+    }
+
+    fun unregister(extension: String) {
+        translators -= extension
+    }
+
+    /** The Kotlin file to compile for the script [id]: [source] itself unless a translator takes it. */
+    internal fun kotlinSource(id: ScriptId, source: File): File {
+        val translator = translatorOf(id) ?: return source
+        val generated = ScriptCache.generatedSource(id)
+        generated.parentFile.mkdirs()
+        generated.writeText(translator.translate(id, source))
+        return generated
+    }
+
+    /** Makes the Kotlin of [id] belong to it, which a compiled artifact names as its source even when it is cached. */
+    internal fun bind(id: ScriptId) {
+        if (translatorOf(id) != null) ScriptRegistry.bind(ScriptCache.generatedSource(id), id)
+    }
+
+    private fun translatorOf(id: ScriptId) = translators.entries.firstOrNull { id.path.endsWith(it.key) }?.value
+
+    /** Every script the engine can compile. */
+    internal fun compilable(): List = ScriptRegistry.list().filter { id ->
+        id.path.endsWith(KOTLIN_SCRIPT) || translators.keys.any(id.path::endsWith)
+    }
+
+    private const val KOTLIN_SCRIPT = ".kts"
+}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt
index fc90c12c8..5a756a822 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/startup/StartupScripts.kt
@@ -8,6 +8,8 @@ import ru.hollowhorizon.hollowengine.client.scripting.StartupScriptNotice
 import ru.hollowhorizon.hollowengine.common.scripting.STARTUP_SCRIPT_EXTENSION
 import ru.hollowhorizon.hollowengine.common.scripting.ScriptEventHandlers
 import ru.hollowhorizon.hollowengine.common.scripting.ScriptLoader
+import ru.hollowhorizon.hollowengine.common.scripting.ScriptFailedEvent
+import ru.hollowhorizon.hollowengine.common.scripting.ScriptStartedEvent
 import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses
 import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId
 import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports
@@ -45,8 +47,10 @@ object StartupScripts {
     }.onSuccess { script ->
         SharedScriptClasses.keepInstance(script::class)
         ScriptEventHandlers.subscribe(id, script, scope)
+        ScriptStartedEvent.post(ScriptStartedEvent(id, script, scope))
     }.onFailure { error ->
         HollowEngine.LOGGER.error("Failed to execute startup script: {}", ScriptRegistry.display(id), error)
+        ScriptFailedEvent.post(ScriptFailedEvent(id))
     }.isSuccess
 
     private fun orderedScripts(): List {
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt
deleted file mode 100644
index 2993e0c32..000000000
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/PersistentWait.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.scripting.state
-
-import kotlinx.coroutines.delay
-import net.minecraft.nbt.CompoundTag
-import ru.hollowhorizon.hollowengine.common.utils.currentServer
-import java.lang.Math.addExact
-import java.time.Instant
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
-
-/**
- * Waits for loaded server ticks and resumes from the same deadline after a save and restart.
- * Must be called from an `@State` function.
- */
-suspend fun waitPersistent(name: String, ticks: Int) {
-    waitPersistent(
-        tag = stateTag(),
-        name = name,
-        ticks = ticks,
-        currentTick = { currentServer.overworld().gameTime },
-        pause = { delay(TICK_DURATION) },
-    )
-}
-
-/**
- * Waits for wall-clock time, including time for which the game was not running.
- * Must be called from an `@State` function.
- */
-suspend fun waitRealtime(name: String, duration: Duration) {
-    require(duration.isFinite() && duration >= Duration.ZERO) {
-        "Realtime wait duration must be finite and non-negative"
-    }
-    val now = System.currentTimeMillis()
-    waitUntil(name, Instant.ofEpochMilli(addExact(now, duration.inWholeMilliseconds)))
-}
-
-/**
- * Waits until an absolute date while preserving the first stored deadline across restarts.
- * Must be called from an `@State` function.
- */
-suspend fun waitUntil(name: String, instant: Instant) {
-    waitUntil(
-        tag = stateTag(),
-        name = name,
-        requestedDeadline = instant.toEpochMilli(),
-        nowMillis = System::currentTimeMillis,
-        pause = { delay(it.milliseconds) },
-    )
-}
-
-internal suspend fun waitPersistent(
-    tag: CompoundTag,
-    name: String,
-    ticks: Int,
-    currentTick: () -> Long,
-    pause: suspend () -> Unit,
-) {
-    require(ticks >= 0) { "Persistent wait duration cannot be negative" }
-    val key = waitKey(GAME_TIME_KIND, name)
-    val deadline = if (tag.contains(key)) {
-        tag.getLong(key)
-    } else {
-        addExact(currentTick(), ticks.toLong()).also { tag.putLong(key, it) }
-    }
-
-    while (currentTick() < deadline) pause()
-    tag.remove(key)
-}
-
-internal suspend fun waitUntil(
-    tag: CompoundTag,
-    name: String,
-    requestedDeadline: Long,
-    nowMillis: () -> Long,
-    pause: suspend (Long) -> Unit,
-) {
-    val key = waitKey(REAL_TIME_KIND, name)
-    val deadline = if (tag.contains(key)) tag.getLong(key) else requestedDeadline.also { tag.putLong(key, it) }
-
-    while (true) {
-        val remaining = deadline - nowMillis()
-        if (remaining <= 0L) break
-        pause(minOf(remaining, MAX_REALTIME_SLEEP_MILLIS))
-    }
-    tag.remove(key)
-}
-
-private fun waitKey(kind: String, name: String): String {
-    require(name.isNotBlank()) { "Persistent wait name cannot be blank" }
-    return "$WAIT_PREFIX.$kind.$name"
-}
-
-private val TICK_DURATION = 50.milliseconds
-private const val WAIT_PREFIX = "wait$"
-private const val GAME_TIME_KIND = "game"
-private const val REAL_TIME_KIND = "real"
-private const val MAX_REALTIME_SLEEP_MILLIS = 60_000L
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt
deleted file mode 100644
index 8d04d1ecf..000000000
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateExecutor.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.scripting.state
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import net.minecraft.nbt.CompoundTag
-import kotlin.coroutines.AbstractCoroutineContextElement
-import kotlin.coroutines.CoroutineContext
-
-class StateExecutor(val scope: CoroutineScope) {
-    private val states = mutableMapOf Unit>()
-
-    fun addState(name: String, state: suspend () -> Unit) {
-        states[name] = state
-    }
-
-    fun start(context: StateContext, start: CoroutineStart = CoroutineStart.DEFAULT) {
-        context.executor = this
-
-        context.job = scope.launch(context, start) {
-            while (true) {
-                val nextState = context.nextState ?: break
-                states[nextState]?.invoke()
-
-                context.children.values.forEach {
-                    if (it.job.isActive) it.job.cancel()
-                }
-                context.tag.allKeys.forEach { key ->
-                    states.remove(key)
-                }
-
-                if (nextState == context.nextState) break
-            }
-        }
-    }
-}
-
-suspend fun async(name: String, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend () -> Unit): Job {
-    val state = stateContext()
-    val context = state.children.computeIfAbsent(name) {
-        StateContext(CompoundTag(), nextState = name)
-    }
-    state.executor.addState(name, block)
-    state.executor.start(context, start)
-    return context.job
-}
-
-class StateContext(
-    val tag: CompoundTag = CompoundTag(),
-    val children: MutableMap = mutableMapOf(),
-    var nextState: String? = null,
-) : AbstractCoroutineContextElement(Key) {
-    object Key : CoroutineContext.Key
-
-    internal lateinit var executor: StateExecutor
-    internal lateinit var job: Job
-
-    fun serialize(): CompoundTag {
-        val tag = CompoundTag()
-        nextState?.let { tag.putString("next_state", it) }
-        tag.put("children", CompoundTag().apply {
-            children.forEach { (name, context) -> put(name, context.serialize()) }
-        })
-        tag.put("self", this.tag)
-        return tag
-    }
-
-    companion object {
-
-        fun deserialize(serialized: CompoundTag): StateContext {
-            val children = serialized.getCompound("children")
-            return StateContext(
-                serialized.getCompound("self"),
-                children.allKeys.associateWith { deserialize(children.getCompound(it)) }.toMutableMap(),
-                serialized.getString("next_state")
-            )
-        }
-    }
-}
\ No newline at end of file
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt
deleted file mode 100644
index db8036e2f..000000000
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/state/StateHelper.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.scripting.state
-
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.serialization.InternalSerializationApi
-import kotlinx.serialization.serializer
-import net.minecraft.nbt.CompoundTag
-import net.minecraft.nbt.StringTag
-import ru.hollowhorizon.hollowengine.common.data.DataKey
-import ru.hollowhorizon.hollowengine.common.data.read
-import ru.hollowhorizon.hollowengine.common.data.write
-import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat
-
-internal suspend fun stateContext(): StateContext {
-    return currentCoroutineContext()[StateContext.Key] ?: error("StateContext not found!")
-}
-
-internal suspend fun transition(target: String) {
-    stateContext().nextState = target
-}
-
-suspend fun stateTag(): CompoundTag = stateContext().tag
-
-suspend fun runOnce(name: String, block: suspend () -> Unit) {
-    val tag = stateTag()
-    val list = tag.getList("once_tasks$", 8)
-    val hasEntry = list.map { (it as StringTag).asString }.contains(name)
-    if (hasEntry) return
-
-    block()
-
-    list.add(StringTag.valueOf(name))
-    tag.put("once_tasks$", list)
-}
-
-@OptIn(InternalSerializationApi::class)
-suspend inline fun  remember(name: String, block: suspend () -> T): T {
-    val tag = stateTag()
-
-    val serializer = T::class.serializer()
-
-    tag.get(name)?.let {
-        return NBTFormat.deserialize(serializer, it)
-    }
-
-    val value = block()
-    tag.put(name, NBTFormat.serialize(serializer, value))
-    return value
-}
-
-suspend fun forget(name: String): Boolean {
-    val tag = stateTag()
-    val exists = tag.contains(name)
-    tag.remove(name)
-    return exists
-}
-
-suspend fun  stateGet(key: DataKey): T? =
-    stateTag().read(key) ?: key.defaultValue?.invoke()
-
-suspend fun  stateGetOrPut(key: DataKey, defaultValue: () -> T): T {
-    val tag = stateTag()
-    tag.read(key)?.let { return it }
-    return defaultValue().also { tag.write(key, it) }
-}
-
-suspend fun  stateGetOrPut(key: DataKey): T =
-    stateGetOrPut(key, key.defaultValue ?: error("Data key '${key.name}' has no default value"))
-
-suspend fun  stateSet(key: DataKey, value: T) {
-    stateTag().write(key, value)
-}
-
-suspend fun  stateUpdate(key: DataKey, transform: (T) -> T): T {
-    val current = stateGet(key) ?: error("Data key '${key.name}' is not set and has no default value")
-    return transform(current).also { stateSet(key, it) }
-}
-
-suspend fun stateContains(key: DataKey<*>): Boolean = stateTag().contains(key.name)
-
-suspend fun stateRemove(key: DataKey<*>): Boolean {
-    val tag = stateTag()
-    val existed = tag.contains(key.name)
-    tag.remove(key.name)
-    return existed
-}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt
deleted file mode 100644
index bebe8d7e2..000000000
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCActions.kt
+++ /dev/null
@@ -1,473 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs
-
-import com.mojang.brigadier.StringReader
-import kotlinx.coroutines.delay
-import net.minecraft.ChatFormatting
-import net.minecraft.commands.arguments.item.ItemParser
-import net.minecraft.core.BlockPos
-import net.minecraft.core.Direction
-import net.minecraft.core.RegistryAccess
-import net.minecraft.core.component.DataComponentPatch
-import net.minecraft.core.registries.BuiltInRegistries
-import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket.Action.*
-import net.minecraft.server.level.ServerLevel
-import net.minecraft.util.Mth
-import net.minecraft.world.InteractionHand
-import net.minecraft.world.InteractionResult
-import net.minecraft.world.entity.Entity
-import net.minecraft.world.entity.item.ItemEntity
-import net.minecraft.world.item.ItemStack
-import net.minecraft.world.phys.BlockHitResult
-import net.minecraft.world.phys.Vec3
-import ru.hollowhorizon.hollowengine.common.coroutines.Ref
-import ru.hollowhorizon.hollowengine.common.coroutines.entityRef
-import ru.hollowhorizon.hollowengine.common.entities.NpcEntity
-import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcAction
-import ru.hollowhorizon.hollowengine.common.npcs.actions.NpcActionKeys
-import ru.hollowhorizon.hollowengine.common.npcs.actions.Preempted
-import ru.hollowhorizon.hollowengine.common.npcs.navigation.*
-import ru.hollowhorizon.hollowengine.common.scripting.state.waitPersistent
-import ru.hollowhorizon.hollowengine.common.scripting.state.waitRealtime
-import ru.hollowhorizon.hollowengine.common.scripting.state.waitUntil
-import ru.hollowhorizon.hollowengine.common.utils.*
-import java.time.Instant
-import java.util.*
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
-
-/**
- * Moves the NPC toward the specified entity until the distance becomes less than or equal to the specified value.
- *
- * @param entity The entity to move toward.
- * @param dist The minimum distance to the entity; movement stops when this distance is reached.
- * @param speed The NPC's movement speed.
- */
-suspend fun NpcEntity.move(entity: Entity, dist: Double = 1.5, speed: Double = 1.0): MoveResult =
-    with(server ?: currentServer) {
-        move(entity.entityRef, MoveOptions(arrivalDistance = dist, speed = speed))
-    }
-
-suspend fun NpcEntity.move(target: Ref, options: MoveOptions = MoveOptions()): MoveResult =
-    actions.act(NpcActionKeys.MOVEMENT) { moveToEntity(target, options) }
-
-fun NpcEntity.startMove(target: Ref, options: MoveOptions = MoveOptions()): NpcAction =
-    actions.start(NpcActionKeys.MOVEMENT) { moveToEntity(target, options) }
-
-/**
- * Moves the NPC to the specified entity using the default settings. (Can be specified with a space)
- *
- * @param mob The entity to move to.
- */
-suspend infix fun NpcEntity.move(mob: Entity): MoveResult = move(entity = mob)
-
-/**
- * Moves the NPC to the specified position until the distance becomes less than or equal to the specified value.
- *
- * @param pos The position to move to.
- * @param dist The minimum distance to the position; movement stops when this distance is reached.
- * @param speed The NPC's movement speed.
- */
-suspend fun NpcEntity.move(pos: Vec3, dist: Double = 1.5, speed: Double = 1.0): MoveResult =
-    move(pos, MoveOptions(arrivalDistance = dist, speed = speed))
-
-suspend fun NpcEntity.move(pos: Vec3, options: MoveOptions = MoveOptions()): MoveResult =
-    actions.act(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) }
-
-fun NpcEntity.startMove(pos: Vec3, options: MoveOptions = MoveOptions()): NpcAction =
-    actions.start(NpcActionKeys.MOVEMENT) { moveToPosition({ pos }, options) }
-
-/**
- * Moves the NPC to the specified position using the default settings.
- *
- * @param position The position to move to.
- */
-suspend infix fun NpcEntity.move(position: Vec3): MoveResult = move(pos = position)
-
-/**
- * Moves the NPC through [points] in turn without stopping at any but the last: each one before it counts as
- * passed within [MoveOptions.passDistance], and [MoveOptions.arrivalDistance] is for the last.
- */
-suspend fun NpcEntity.move(points: List, options: MoveOptions = MoveOptions()): MoveResult {
-    require(points.isNotEmpty()) { "A move needs at least one point" }
-    return actions.act(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) }
-}
-
-fun NpcEntity.startMove(points: List, options: MoveOptions = MoveOptions()): NpcAction {
-    require(points.isNotEmpty()) { "A move needs at least one point" }
-    return actions.start(NpcActionKeys.MOVEMENT) { moveThrough(points.map { point -> { point } }, options) }
-}
-
-fun NpcEntity.stopMoving() {
-    actions.cancel(NpcActionKeys.MOVEMENT)
-    navigation.stop()
-}
-
-/**
- * Runs several actions of the NPC as one: the channels they use stay held until [block] returns, and losing
- * any of them to a higher priority cancels the whole block with a single [Preempted].
- */
-suspend fun  NpcEntity.perform(block: suspend NpcEntity.() -> R): R = actions.perform { block() }
-
-/** [perform] that waits for the actions already running on its channels instead of replacing them. */
-suspend fun  NpcEntity.queue(block: suspend NpcEntity.() -> R): R = actions.queue { block() }
-
-/**
- * Keeps the NPC out of [zone] on every way it walks, its moves and its goals alike, until [stopAvoiding]. It is
- * not saved with the NPC: a script that needs it after a reload adds it again.
- */
-fun NpcEntity.avoid(zone: Zone) {
-    npcNavigation.avoidedZones += zone
-    navigation.recomputePath()
-}
-
-fun NpcEntity.stopAvoiding(zone: Zone) {
-    if (npcNavigation.avoidedZones.remove(zone)) navigation.recomputePath()
-}
-
-fun NpcEntity.stopAvoidingAll() {
-    if (npcNavigation.avoidedZones.isEmpty()) return
-    npcNavigation.avoidedZones.clear()
-    navigation.recomputePath()
-}
-
-/**
- * Makes the NPC look at the specified position. Returns value, is npc looked at target, or still rotating head.
- *
- * @param position The position to look at.
- */
-fun NpcEntity.lookAtNow(position: Vec3, maxAngularSpeed: Float = 360f): Boolean =
-    faceTowards(position, maxAngularSpeed)
-
-fun NpcEntity.startLookingAt(
-    position: Vec3,
-    duration: Duration = DEFAULT_LOOK_DURATION,
-    maxAngularSpeed: Float = 360f,
-): NpcAction = actions.start(NpcActionKeys.LOOK) {
-    rotate({ position }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed)
-}
-
-suspend fun NpcEntity.lookAt(position: Vec3, duration: Duration, maxAngularSpeed: Float = 360f) {
-    actions.act(NpcActionKeys.LOOK) { rotate({ position }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed) }
-}
-
-suspend infix fun NpcEntity.lookAt(position: Vec3) {
-    lookAt(position, DEFAULT_LOOK_DURATION)
-}
-
-/**
- * Makes the NPC look at the specified entity. Returns value, is npc looked at target, or still rotating head.
- *
- * @param entity The entity to look at.
- */
-fun NpcEntity.lookAtNow(entity: Entity, maxAngularSpeed: Float = 360f): Boolean =
-    faceTowards(entity, maxAngularSpeed)
-
-fun NpcEntity.startLookingAt(
-    target: Ref,
-    duration: Duration = DEFAULT_LOOK_DURATION,
-    maxAngularSpeed: Float = 360f,
-): NpcAction = actions.start(NpcActionKeys.LOOK) {
-    rotate({ target.resolve().eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed)
-}
-
-suspend fun NpcEntity.lookAt(
-    target: Ref,
-    duration: Duration,
-    maxAngularSpeed: Float = 360f,
-) {
-    actions.act(NpcActionKeys.LOOK) {
-        rotate({ target.resolve().eyePosition }, duration.inWholeMilliseconds, maxAngularSpeed = maxAngularSpeed)
-    }
-}
-
-suspend infix fun NpcEntity.lookAt(entity: Entity) {
-    with(server ?: currentServer) {
-        lookAt(entity.entityRef, DEFAULT_LOOK_DURATION)
-    }
-}
-
-fun NpcEntity.clearLookTarget() {
-    actions.cancel(NpcActionKeys.LOOK)
-}
-
-/**
- * Forces an NPC to use a block at the specified position.
- * The NPC will move to the block, look at it, and perform the use action.
- *
- * @param pos The position of the block to be used.
- */
-suspend infix fun NpcEntity.useBlock(pos: Vec3): InteractionResult = useBlock(pos, InteractionHand.MAIN_HAND)
-
-suspend fun NpcEntity.useBlock(
-    pos: Vec3,
-    hand: InteractionHand = InteractionHand.MAIN_HAND,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-    face: Direction? = null,
-): InteractionResult {
-    require(reach > 0.0) { "Interaction reach must be greater than zero" }
-    return actions.act(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) }
-}
-
-fun NpcEntity.startUseBlock(
-    pos: Vec3,
-    hand: InteractionHand = InteractionHand.MAIN_HAND,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-    face: Direction? = null,
-): NpcAction {
-    require(reach > 0.0) { "Interaction reach must be greater than zero" }
-    return actions.start(NpcActionKeys.HANDS) { useBlockReaching(pos, hand, reach, face) }
-}
-
-private suspend fun NpcEntity.useBlockReaching(
-    pos: Vec3,
-    hand: InteractionHand,
-    reach: Double,
-    face: Direction?,
-): InteractionResult {
-    val target = Vec3.atCenterOf(BlockPos.containing(pos))
-    if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return InteractionResult.FAIL
-    rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds)
-    return useBlockAt(target, hand, face)
-}
-
-fun NpcEntity.useBlockNow(
-    pos: Vec3,
-    hand: InteractionHand = InteractionHand.MAIN_HAND,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-    face: Direction? = null,
-): InteractionResult {
-    val blockPos = BlockPos.containing(pos)
-    val target = Vec3.atCenterOf(blockPos)
-    if (eyePosition.distanceToSqr(target) > reach * reach) return InteractionResult.FAIL
-
-    return useBlockAt(target, hand, face)
-}
-
-private fun NpcEntity.useBlockAt(target: Vec3, hand: InteractionHand, face: Direction?): InteractionResult {
-    val blockPos = BlockPos.containing(target)
-    syncFakePlayer()
-    val hit = BlockHitResult(target, face ?: interactionFace(target), blockPos, false)
-    swing(hand)
-    val result = fakePlayer.gameMode.useItemOn(fakePlayer, level(), fakePlayer.getItemInHand(hand), hand, hit)
-    setItemInHand(hand, fakePlayer.getItemInHand(hand).copy())
-    return result
-}
-
-/**
- * Forces the NPC to destroy the block at the specified position.
- * The NPC will move to the block, look at it, and destroy it.
- *
- * @param pos The position of the block to be destroyed.
- */
-suspend infix fun NpcEntity.destroyBlock(pos: Vec3): Boolean = destroyBlock(pos, DEFAULT_INTERACTION_REACH)
-
-suspend fun NpcEntity.destroyBlock(
-    pos: Vec3,
-    reach: Double,
-    requireCorrectTool: Boolean = true,
-): Boolean = actions.act(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) }
-
-fun NpcEntity.startDestroyBlock(
-    pos: Vec3,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-    requireCorrectTool: Boolean = true,
-): NpcAction = actions.start(NpcActionKeys.HANDS) { destroyBlockReaching(pos, reach, requireCorrectTool) }
-
-private suspend fun NpcEntity.destroyBlockReaching(pos: Vec3, reach: Double, requireCorrectTool: Boolean): Boolean {
-    val blockPos = BlockPos.containing(pos)
-    val target = Vec3.atCenterOf(blockPos)
-    if (move(target, MoveOptions(arrivalDistance = reach * 0.8f)) != MoveResult.Arrived) return false
-    rotate({ target }, DEFAULT_LOOK_DURATION.inWholeMilliseconds)
-    syncFakePlayer()
-    val initialState = level().getBlockState(blockPos)
-    if (initialState.isAir || initialState.getDestroyProgress(fakePlayer, level(), blockPos) <= 0f) return false
-    if (requireCorrectTool && !fakePlayer.hasCorrectToolForDrops(initialState)) return false
-
-    val face = interactionFace(target)
-    fakePlayer.gameMode.handleBlockBreakAction(blockPos, START_DESTROY_BLOCK, face, level().maxBuildHeight, 0)
-    try {
-        var elapsedTicks = 0
-        while (level().getBlockState(blockPos) == initialState) {
-            fakePlayer.gameMode.tick()
-            elapsedTicks++
-            faceTowards(target)
-            swing(InteractionHand.MAIN_HAND)
-            val progress = initialState.getDestroyProgress(fakePlayer, level(), blockPos)
-            if (progress * (elapsedTicks + 1) >= 1f) {
-                fakePlayer.gameMode.handleBlockBreakAction(
-                    blockPos,
-                    STOP_DESTROY_BLOCK,
-                    face,
-                    level().maxBuildHeight,
-                    0,
-                )
-                break
-            }
-            delay(TICK_DURATION)
-        }
-        return level().getBlockState(blockPos).isAir
-    } finally {
-        if (!level().getBlockState(blockPos).isAir) {
-            fakePlayer.gameMode.handleBlockBreakAction(
-                blockPos,
-                ABORT_DESTROY_BLOCK,
-                face,
-                level().maxBuildHeight,
-                0,
-            )
-        }
-        level().destroyBlockProgress(fakePlayer.id, blockPos, -1)
-        setItemInHand(InteractionHand.MAIN_HAND, fakePlayer.mainHandItem.copy())
-    }
-}
-
-private fun NpcEntity.interactionFace(target: Vec3): Direction = Direction.getNearest(eyePosition.subtract(target))
-
-fun NpcEntity.interactNow(
-    target: Entity,
-    hand: InteractionHand = InteractionHand.MAIN_HAND,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-): InteractionResult {
-    if (target.level() !== level() || distanceToSqr(target) > reach * reach) return InteractionResult.FAIL
-    syncFakePlayer()
-    val result = fakePlayer.interactOn(target, hand)
-    setItemInHand(hand, fakePlayer.getItemInHand(hand).copy())
-    swing(hand)
-    return result
-}
-
-fun NpcEntity.startInteract(
-    target: Ref,
-    hand: InteractionHand = InteractionHand.MAIN_HAND,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-): NpcAction = actions.start(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) }
-
-private suspend fun NpcEntity.interactReaching(
-    target: Ref,
-    hand: InteractionHand,
-    reach: Double,
-): InteractionResult {
-    if (move(target, MoveOptions(arrivalDistance = reach)) != MoveResult.Arrived) return InteractionResult.FAIL
-    val entity = target.resolve()
-    rotate({ entity.eyePosition }, DEFAULT_LOOK_DURATION.inWholeMilliseconds)
-    return interactNow(entity, hand, reach)
-}
-
-suspend fun NpcEntity.interact(
-    target: Ref,
-    hand: InteractionHand = InteractionHand.MAIN_HAND,
-    reach: Double = DEFAULT_INTERACTION_REACH,
-): InteractionResult = actions.act(NpcActionKeys.HANDS) { interactReaching(target, hand, reach) }
-
-private fun NpcEntity.syncFakePlayer() {
-    val level = level() as ServerLevel
-    fakePlayer.gameMode.setLevel(level)
-    fakePlayer.moveTo(x, y, z, yRot, xRot)
-    InteractionHand.entries.forEach { currentHand ->
-        fakePlayer.setItemInHand(currentHand, getItemInHand(currentHand).copy())
-    }
-}
-
-/**
- * Forces an NPC to drop an item from its inventory.
- *
- * @param item The item to be dropped.
- */
-fun NpcEntity.dropItem(item: ItemStack) {
-    val p = position()
-    val entityStack = ItemEntity(level(), p.x, p.y + eyeHeight, p.z, item)
-    entityStack.setDefaultPickUpDelay()
-    val f8 = Mth.sin(xRot * Mth.PI / 180f)
-    val f3 = Mth.sin(yHeadRot * Mth.PI / 180f)
-    val f4 = Mth.cos(yHeadRot * Mth.PI / 180f)
-    entityStack.setDeltaMovement(-f3 * 0.3, -f8 * 0.3 + 0.1, f4 * 0.3)
-    level().addFreshEntity(entityStack)
-}
-
-/**
- * Pauses the coroutine for the specified number of ticks.
- *
- * @param time The number of ticks to delay.
- */
-suspend fun wait(time: Int) {
-    delay((time * 50L).milliseconds)
-}
-
-/** Waits for loaded server ticks inside `@State` and resumes the same countdown after a restart. */
-suspend fun wait(time: Int, name: String) {
-    waitPersistent(name, time)
-}
-
-/** Waits inside `@State` for wall-clock time, including time for which the game was not running. */
-suspend fun wait(time: Duration, name: String) {
-    waitRealtime(name, time)
-}
-
-/** Waits inside `@State` until an absolute date and resumes the same deadline after a restart. */
-suspend fun wait(time: Instant, name: String) {
-    waitUntil(name, time)
-}
-
-fun uuid(uuid: String): UUID = UUID.fromString(uuid)
-
-/**
- * Π‘ΠΎΠ·Π΄Π°Π΅Ρ‚ ItemStack с ΡƒΠΊΠ°Π·Π°Π½Π½Ρ‹ΠΌ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚ΠΎΠΌ, количСством ΠΈ NBT-Ρ‚Π΅Π³ΠΎΠΌ.
- *
- * @param item Π˜Π΄Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ‚ΠΎΡ€ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚Π° (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€, "minecraft:apple").
- * @param count ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚ΠΎΠ² Π² стакС.
- * @param nbt NBT-Ρ‚Π΅Π³ для ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚Π°.
- * @return ItemStack с Π·Π°Π΄Π°Π½Π½Ρ‹ΠΌΠΈ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Π°ΠΌΠΈ.
- */
-fun item(item: String, count: Int = 1): ItemStack = item(item, count, DataComponentPatch.EMPTY)
-
-private val builtInRegistryAccess by lazy {
-    RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY)
-}
-
-fun item(item: String, count: Int = 1, components: DataComponentPatch): ItemStack {
-    require(count > 0) { "Item count must be greater than zero" }
-    val reader = StringReader(item)
-    val registries = currentServerOrNull()?.registryAccess() ?: builtInRegistryAccess
-    val parsed = runCatching { ItemParser(registries).parse(reader) }
-        .getOrElse { error -> throw IllegalArgumentException("Invalid item specification '$item'", error) }
-    require(!reader.canRead()) { "Unexpected trailing data in item specification '$item'" }
-
-    return ItemStack(parsed.item, count).apply {
-        applyComponents(parsed.components)
-        applyComponents(components)
-    }
-}
-
-/**
- * A getter variable that converts seconds to ticks.
- *
- * @param this The number to be converted from ticks to seconds
- * @template
- * ```node.kts
- * 10.sec // Converts 10 seconds to ticks (10 * 20 = 200)
- * ```
- */
-val Int.sec get() = this * 20
-
-val Double.sec get() = (this * 20).toInt()
-
-private val DEFAULT_LOOK_DURATION = 1500.milliseconds
-private val TICK_DURATION = 50.milliseconds
-private const val DEFAULT_INTERACTION_REACH = 3.0
-
-/**
- * Makes an NPC "say" the text by sending a message to all players on the server.
- *
- * @param text The text of the message.
- */
-infix fun NpcEntity.say(text: String) {
-    server?.playerList?.players?.forEach {
-        it.sendSystemMessage(
-            "[".literal.colored(ChatFormatting.GRAY) +
-            name +
-            "]: ".literal.colored(ChatFormatting.GRAY) +
-            text.literal
-        )
-    }
-}
-
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt
index 7e59e8ac3..aa384a9f8 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCInventoryActions.kt
@@ -1,6 +1,8 @@
 package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs
 
+import net.minecraft.util.Mth
 import net.minecraft.world.entity.EquipmentSlot
+import net.minecraft.world.entity.item.ItemEntity
 import net.minecraft.world.entity.player.Player
 import net.minecraft.world.item.ItemStack
 import ru.hollowhorizon.hollowengine.common.entities.NpcEntity
@@ -50,3 +52,19 @@ fun NpcEntity.transferItems(target: NpcEntity, filter: ItemFilter, count: Int):
 
 fun NpcEntity.transferItems(target: Player, filter: ItemFilter, count: Int): Int =
     inventory.slots.transferTo(target.storageSlots(), filter, count)
+
+/**
+ * Forces an NPC to drop an item from its inventory.
+ *
+ * @param item The item to be dropped.
+ */
+fun NpcEntity.dropItem(item: ItemStack) {
+    val p = position()
+    val entityStack = ItemEntity(level(), p.x, p.y + eyeHeight, p.z, item)
+    entityStack.setDefaultPickUpDelay()
+    val f8 = Mth.sin(xRot * Mth.PI / 180f)
+    val f3 = Mth.sin(yHeadRot * Mth.PI / 180f)
+    val f4 = Mth.cos(yHeadRot * Mth.PI / 180f)
+    entityStack.setDeltaMovement(-f3 * 0.3, -f8 * 0.3 + 0.1, f4 * 0.3)
+    level().addFreshEntity(entityStack)
+}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCLifecycle.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCLifecycle.kt
index 450eebbc4..e3147410d 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCLifecycle.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/NPCLifecycle.kt
@@ -1,5 +1,6 @@
 package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs
 
+import net.minecraft.server.level.ServerLevel
 import net.minecraft.world.entity.Entity
 import net.minecraft.world.phys.Vec2
 import net.minecraft.world.phys.Vec3
@@ -10,11 +11,11 @@ import ru.hollowhorizon.hollowengine.common.attachments.components.TransformComp
 import ru.hollowhorizon.hollowengine.common.entities.NpcEntity
 import ru.hollowhorizon.hollowengine.common.entities.setNameplate
 import ru.hollowhorizon.hollowengine.common.npcs.inventory.NpcInventory
+import ru.hollowhorizon.hollowengine.common.npcs.registry.ServerNpcs
 import ru.hollowhorizon.hollowengine.common.npcs.registry.npcs
 import ru.hollowhorizon.hollowengine.common.utils.currentServer
 import ru.hollowhorizon.hollowengine.common.utils.isValidRL
 import ru.hollowhorizon.hollowengine.common.utils.math.TrsTransformF
-import kotlin.contracts.ExperimentalContracts
 
 /**
  * Π‘ΠΎΠ·Π΄Π°Ρ‘Ρ‚ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π½ΠΎΠ²ΠΎΠ³ΠΎ NPC (`NpcEntity`) Π² ΡƒΠΊΠ°Π·Π°Π½Π½ΠΎΠΉ ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ ΠΈ с Π·Π°Π΄Π°Π½Π½Ρ‹ΠΌΠΈ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Π°ΠΌΠΈ внСшнСго Π²ΠΈΠ΄Π°, повСдСния ΠΈ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ.
@@ -46,7 +47,7 @@ import kotlin.contracts.ExperimentalContracts
  *
  * @return Π‘ΠΎΠ·Π΄Π°Π½Π½Ρ‹ΠΉ экзСмпляр [NpcEntity], Π΄ΠΎΠ±Π°Π²Π»Π΅Π½Π½Ρ‹ΠΉ Π² ΠΌΠΈΡ€.
  */
-@OptIn(ExperimentalContracts::class)
+@Deprecated("Use ServerLevel.npc(pos, id) { ... } to configure the NPC before spawning.")
 fun npc(
     pos: Vec3,
     name: String = "NPC",
@@ -71,7 +72,7 @@ fun npc(
         ?: throw IllegalStateException("Dimension $world not found!"))
         ?: throw IllegalStateException("Dimension $world is not loaded!")
 
-    return NpcEntity(level).apply {
+    return level.npc(pos, id) {
         inventory.resize(inventorySize)
         moveTo(pos.x, pos.y, pos.z, rotation.x, rotation.y)
         yHeadRot = rotation.x
@@ -84,14 +85,37 @@ fun npc(
             setAttributes(attributes)
         }
 
-        refreshDimensions()
-
         setNameplate(name, nameplateMode)
-
-        npcs.spawn(this, id)
     }
 }
 
+/**
+ * Spawns an NPC at [pos] in this level, after [builder] set it up: model, name, attributes and the rest.
+ *
+ * @param id the author's id, such as `"village:blacksmith"`. When a live NPC has it, that NPC is returned and
+ * [builder] does not run, so a scene that starts again makes no twins.
+ */
+fun ServerLevel.npc(
+    pos: Vec3,
+    id: String? = null,
+    builder: NpcEntity.() -> Unit = {},
+): NpcEntity {
+    val npcs = server.npcs
+    if (id != null) npcs.alive(id)?.let { return it }
+    return buildNpc(pos, builder).also { npcs.spawn(it, id) }
+}
+
+/** An NPC at [pos] set up by [builder], not yet in the world: [ServerNpcs.spawn] adds it. */
+fun ServerLevel.buildNpc(pos: Vec3, builder: NpcEntity.() -> Unit = {}): NpcEntity = NpcEntity(this).apply {
+    moveTo(pos.x, pos.y, pos.z, 0f, 0f)
+    set(Model())
+    set(TransformComponent())
+    setNameplate("NPC")
+
+    builder()
+    refreshDimensions()
+}
+
 /**
  * Keeps the chunks around the NPC loaded while [block] runs, for walks and other actions that must finish even
  * where no player is. [NpcEntity.isForceLoaded] does the same for good.
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt
new file mode 100644
index 000000000..8f698682a
--- /dev/null
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/npcs/ScriptUtilities.kt
@@ -0,0 +1,86 @@
+package ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs
+
+import com.mojang.brigadier.StringReader
+import kotlinx.coroutines.delay
+import net.minecraft.ChatFormatting
+import net.minecraft.commands.arguments.item.ItemParser
+import net.minecraft.core.RegistryAccess
+import net.minecraft.core.component.DataComponentPatch
+import net.minecraft.core.registries.BuiltInRegistries
+import net.minecraft.world.item.ItemStack
+import ru.hollowhorizon.hollowengine.common.entities.NpcEntity
+import ru.hollowhorizon.hollowengine.common.utils.colored
+import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull
+import ru.hollowhorizon.hollowengine.common.utils.literal
+import ru.hollowhorizon.hollowengine.common.utils.plus
+import java.util.UUID
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Pauses the coroutine for the specified number of ticks.
+ *
+ * @param time The number of ticks to delay.
+ */
+suspend fun wait(time: Int) {
+    delay((time * 50L).milliseconds)
+}
+
+fun uuid(uuid: String): UUID = UUID.fromString(uuid)
+
+/**
+ * Π‘ΠΎΠ·Π΄Π°Π΅Ρ‚ ItemStack с ΡƒΠΊΠ°Π·Π°Π½Π½Ρ‹ΠΌ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚ΠΎΠΌ, количСством ΠΈ NBT-Ρ‚Π΅Π³ΠΎΠΌ.
+ *
+ * @param item Π˜Π΄Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ‚ΠΎΡ€ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚Π° (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€, "minecraft:apple").
+ * @param count ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚ΠΎΠ² Π² стакС.
+ * @param nbt NBT-Ρ‚Π΅Π³ для ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚Π°.
+ * @return ItemStack с Π·Π°Π΄Π°Π½Π½Ρ‹ΠΌΠΈ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Π°ΠΌΠΈ.
+ */
+fun item(item: String, count: Int = 1): ItemStack = item(item, count, DataComponentPatch.EMPTY)
+
+private val builtInRegistryAccess by lazy {
+    RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY)
+}
+
+fun item(item: String, count: Int = 1, components: DataComponentPatch): ItemStack {
+    require(count > 0) { "Item count must be greater than zero" }
+    val reader = StringReader(item)
+    val registries = currentServerOrNull()?.registryAccess() ?: builtInRegistryAccess
+    val parsed = runCatching { ItemParser(registries).parse(reader) }
+        .getOrElse { error -> throw IllegalArgumentException("Invalid item specification '$item'", error) }
+    require(!reader.canRead()) { "Unexpected trailing data in item specification '$item'" }
+
+    return ItemStack(parsed.item, count).apply {
+        applyComponents(parsed.components)
+        applyComponents(components)
+    }
+}
+
+/**
+ * A getter variable that converts seconds to ticks.
+ *
+ * @param this The number to be converted from ticks to seconds
+ * @template
+ * ```node.kts
+ * 10.sec // Converts 10 seconds to ticks (10 * 20 = 200)
+ * ```
+ */
+val Int.sec get() = this * 20
+
+val Double.sec get() = (this * 20).toInt()
+
+/**
+ * Makes an NPC "say" the text by sending a message to all players on the server.
+ *
+ * @param text The text of the message.
+ */
+infix fun NpcEntity.say(text: String) {
+    server?.playerList?.players?.forEach {
+        it.sendSystemMessage(
+            "[".literal.colored(ChatFormatting.GRAY) +
+            name +
+            "]: ".literal.colored(ChatFormatting.GRAY) +
+            text.literal
+        )
+    }
+}
+
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ui/UiScript.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ui/UiScript.kt
index f4c4bb557..8c1afeeea 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ui/UiScript.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ui/UiScript.kt
@@ -165,22 +165,77 @@ class UiScreenBuilder internal constructor(id: ResourceLocation) : UiHostBuilder
     )
 }
 
+/**
+ * What every host that can be an overlay has: where it draws and whether it takes input.
+ */
+sealed class UiOverlayHostBuilder(id: ResourceLocation) : UiHostBuilder(id) {
+    private var anchors: List = listOf(after(VanillaHudLayers.HOTBAR.toString()))
+
+    /**
+     * Whether this overlay takes pointer/keyboard input. Draw-only ([OverlayInput.NONE]) is the
+     * default; set [OverlayInput.INTERACTIVE] for an overlay the player clicks.
+     */
+    var input: OverlayInput = OverlayInput.NONE
+
+    /** Shorthand for `input = OverlayInput.INTERACTIVE`. */
+    fun interactive() {
+        input = OverlayInput.INTERACTIVE
+    }
+
+    /**
+     * Where the overlay draws, in priority; after the hotbar by default:
+     *
+     * ```kotlin
+     * anchor(after("chat"), over(ChatScreen::class))
+     * ```
+     *
+     * While a screen is open the first [over] that accepts it wins; otherwise the overlay stays in the HUD at its
+     * [after] or [before], under the screen. At most one HUD anchor is allowed.
+     */
+    fun anchor(vararg anchors: OverlayAnchor) {
+        require(anchors.isNotEmpty()) { "UI overlay '$id' needs an anchor" }
+        this.anchors = anchors.toList()
+    }
+
+    /** In the HUD after the vanilla [layer]; see [VanillaHudLayers]. Bare names such as `"crosshair"` are accepted. */
+    fun after(layer: String) = OverlayAnchor.HudLayer(VanillaHudLayers.parse(layer), HudPlacement.AFTER)
+
+    /** In the HUD before the vanilla [layer]; see [after]. */
+    fun before(layer: String) = OverlayAnchor.HudLayer(VanillaHudLayers.parse(layer), HudPlacement.BEFORE)
+
+    /** On top of any open screen. */
+    fun over(level: ScreenLevel = ScreenLevel.CONTENT) = OverlayAnchor.OnScreen(ScreenMatch.AnyScreen, level)
+
+    /** On top of an open [screen], or of one of its subclasses unless [subclasses] is off. */
+    fun over(screen: KClass, level: ScreenLevel = ScreenLevel.CONTENT, subclasses: Boolean = true) =
+        over(screen.java.name, level, subclasses)
+
+    /** On top of a screen named by its binary class name, for a class this script cannot import. */
+    fun over(screen: String, level: ScreenLevel = ScreenLevel.CONTENT, subclasses: Boolean = true) =
+        OverlayAnchor.OnScreen(ScreenMatch.OfType(screen, subclasses), level)
+
+    protected fun overlayDefinition(content: UiContent, autoShow: Boolean) = UiOverlayDefinition(
+        id = id,
+        anchors = anchors,
+        autoShow = autoShow,
+        input = input,
+        rebuildEveryFrame = rebuildEveryFrame,
+        exitDuration = exitDuration,
+        content = content,
+    )
+}
+
 /**
  * Declares a surface that switches between an overlay and a screen. It carries both option sets,
  * the overlay's anchor and the screen's title, escape handling and scale, because the same surface
  * really is both at different moments.
  */
-class UiSurfaceBuilder internal constructor(id: ResourceLocation) : UiHostBuilder(id) {
+class UiSurfaceBuilder internal constructor(id: ResourceLocation) : UiOverlayHostBuilder(id) {
     var title: String = id.path
     var closeOnEscape: Boolean = true
     var pausesGame: Boolean = false
     var guiScale: UiGuiScale = UiGuiScale.Inherit
 
-    var anchor: String = VanillaHudLayers.HOTBAR.toString()
-    var placement: HudPlacement = HudPlacement.AFTER
-    var aboveScreens: Boolean = false
-    var input: OverlayInput = OverlayInput.NONE
-
     private var mode: ((UiData) -> UiSurfaceKind)? = null
 
     /**
@@ -191,11 +246,6 @@ class UiSurfaceBuilder internal constructor(id: ResourceLocation) : UiHostBuilde
         mode = selector
     }
 
-    /** Shorthand for `input = OverlayInput.INTERACTIVE`; only matters while it is an overlay. */
-    fun interactive() {
-        input = OverlayInput.INTERACTIVE
-    }
-
     internal fun build(): UiSurfaceDefinition {
         val body = requireContent("surface")
         return UiSurfaceDefinition(
@@ -210,32 +260,13 @@ class UiSurfaceBuilder internal constructor(id: ResourceLocation) : UiHostBuilde
                 exitDuration = exitDuration,
                 content = body,
             ),
-            overlay = UiOverlayDefinition(
-                id = id,
-                anchor = VanillaHudLayers.parse(anchor),
-                placement = placement,
-                autoShow = false,
-                input = input,
-                aboveScreens = aboveScreens,
-                rebuildEveryFrame = rebuildEveryFrame,
-                exitDuration = exitDuration,
-                content = body,
-            ),
+            overlay = overlayDefinition(body, autoShow = false),
             mode = mode ?: error("UI surface '$id' declares no mode { } block"),
         )
     }
 }
 
-class UiOverlayBuilder internal constructor(id: ResourceLocation) : UiHostBuilder(id) {
-    /**
-     * The HUD layer this overlay draws next to; see [VanillaHudLayers]. Accepts bare vanilla names
-     * (`"crosshair"`) as well as fully qualified ids.
-     */
-    var anchor: String = VanillaHudLayers.HOTBAR.toString()
-
-    /** Whether this overlay draws before or after its [anchor]. */
-    var placement: HudPlacement = HudPlacement.AFTER
-
+class UiOverlayBuilder internal constructor(id: ResourceLocation) : UiOverlayHostBuilder(id) {
     /**
      * Shows the overlay as soon as the script loads, without waiting for the server. This is the
      * mode for client-owned HUD, a hint about the block under the crosshair has no business
@@ -243,32 +274,5 @@ class UiOverlayBuilder internal constructor(id: ResourceLocation) : UiHostBuilde
      */
     var autoShow: Boolean = false
 
-    /**
-     * Whether this overlay takes pointer/keyboard input. Draw-only ([OverlayInput.NONE]) is the
-     * default; set [OverlayInput.INTERACTIVE] for an overlay the player clicks.
-     */
-    var input: OverlayInput = OverlayInput.NONE
-
-    /**
-     * Whether the overlay also draws (and takes input, if interactive) on top of an open screen,
-     * rather than only over the in-game HUD. Use it for things that must stay visible above a menu.
-     */
-    var aboveScreens: Boolean = false
-
-    /** Shorthand for `input = OverlayInput.INTERACTIVE`. */
-    fun interactive() {
-        input = OverlayInput.INTERACTIVE
-    }
-
-    internal fun build(): UiOverlayDefinition = UiOverlayDefinition(
-        id = id,
-        anchor = VanillaHudLayers.parse(anchor),
-        placement = placement,
-        autoShow = autoShow,
-        input = input,
-        aboveScreens = aboveScreens,
-        rebuildEveryFrame = rebuildEveryFrame,
-        exitDuration = exitDuration,
-        content = requireContent("overlay"),
-    )
+    internal fun build(): UiOverlayDefinition = overlayDefinition(requireContent("overlay"), autoShow)
 }
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/OverlayAnchor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/OverlayAnchor.kt
new file mode 100644
index 000000000..d166cfe5b
--- /dev/null
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/OverlayAnchor.kt
@@ -0,0 +1,52 @@
+package ru.hollowhorizon.hollowengine.common.ui
+
+import net.minecraft.resources.ResourceLocation
+
+/**
+ * A place an overlay draws in. An overlay lists them in priority: while a screen is open, the first [OnScreen]
+ * that accepts it wins; otherwise the overlay draws at its [HudLayer], if it has one.
+ */
+sealed interface OverlayAnchor {
+    /**
+     * Next to a vanilla HUD layer. The game draws the HUD under an open screen, and the overlay with it; there it
+     * takes no input, and with no screen its pointer is the center of the screen.
+     */
+    data class HudLayer(val layer: ResourceLocation, val placement: HudPlacement) : OverlayAnchor
+
+    /** On top of an open screen that [screens] accepts, at [level]. The overlay takes the cursor of that screen. */
+    data class OnScreen(val screens: ScreenMatch, val level: ScreenLevel) : OverlayAnchor
+}
+
+/** Where on an open screen an [OverlayAnchor.OnScreen] overlay is drawn. */
+enum class ScreenLevel {
+    /**
+     * Over the background of the screen, the dimmed or blurred world, and under everything the screen draws on it.
+     * The screen gets the pointer first wherever it has something to click.
+     */
+    BACKGROUND,
+
+    /** Over what the screen draws, under its tooltips. */
+    CONTENT,
+
+    /** Over everything, tooltips included. */
+    TOP,
+}
+
+/** Which open screens an [OverlayAnchor.OnScreen] overlay rises above. */
+sealed interface ScreenMatch {
+    /** Whether a screen of [type] is one of them. */
+    fun matches(type: Class<*>): Boolean
+
+    data object AnyScreen : ScreenMatch {
+        override fun matches(type: Class<*>): Boolean = true
+    }
+
+    /**
+     * Screens of the class with the binary name [className], as `override(...)` names it, and of its subclasses
+     * when [subclasses] is set.
+     */
+    data class OfType(val className: String, val subclasses: Boolean) : ScreenMatch {
+        override fun matches(type: Class<*>): Boolean =
+            if (subclasses) generateSequence(type) { it.superclass }.any { it.name == className } else type.name == className
+    }
+}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt
index e48ddf125..24b04518a 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitionRegistry.kt
@@ -2,6 +2,8 @@ package ru.hollowhorizon.hollowengine.common.ui
 
 import net.minecraft.resources.ResourceLocation
 import ru.hollowhorizon.hollowengine.HollowEngine
+import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints
+import ru.hollowhorizon.hollowengine.common.utils.rl
 import java.util.concurrent.ConcurrentHashMap
 
 /**
@@ -9,6 +11,15 @@ import java.util.concurrent.ConcurrentHashMap
  * rebuilt from scratch each time rather than merged, a renamed or deleted screen must not linger.
  */
 object UiDefinitionRegistry {
+    /**
+     * Screens addons ship. They outlive script reloads and are used only while no script declares a
+     * screen with the same id, so a project can replace any of them.
+     */
+    val builtInScreens = ExtensionPoints.create("hollowengine:ui/screens".rl, UiScreenDefinition::class)
+
+    /** HUD layers addons ship; like [builtInScreens], a script declaring the same id replaces one. */
+    val builtInOverlays = ExtensionPoints.create("hollowengine:ui/overlays".rl, UiOverlayDefinition::class)
+
     private val screens = ConcurrentHashMap()
     private val overlays = ConcurrentHashMap()
     private val surfaces = ConcurrentHashMap()
@@ -45,9 +56,11 @@ object UiDefinitionRegistry {
         surfaces[definition.id] = definition
     }
 
-    fun screen(id: ResourceLocation): UiScreenDefinition? = screens[id]
+    fun screen(id: ResourceLocation): UiScreenDefinition? =
+        screens[id] ?: builtInScreens.extensions.firstOrNull { it.id == id }
 
-    fun overlay(id: ResourceLocation): UiOverlayDefinition? = overlays[id]
+    fun overlay(id: ResourceLocation): UiOverlayDefinition? =
+        overlays[id] ?: builtInOverlays.extensions.firstOrNull { it.id == id }
 
     fun surface(id: ResourceLocation): UiSurfaceDefinition? = surfaces[id]
 
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitions.kt
index 8700f0fc7..4886e7397 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitions.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/UiDefinitions.kt
@@ -17,8 +17,8 @@ enum class OverlayInput {
 
     /**
      * The overlay receives pointer and keyboard input. During gameplay the cursor is locked to the
-     * screen center, so an interactive overlay reads the center point; with [UiOverlayDefinition.aboveScreens]
-     * it can also sit on top of an open screen (e.g. above the chat box) and take the real cursor.
+     * screen center, so an interactive overlay reads the center point; anchored [OverlayAnchor.OnScreen]
+     * it sits on top of an open screen (e.g. above the chat box) and takes the real cursor.
      */
     INTERACTIVE,
 }
@@ -59,9 +59,9 @@ class UiScreenDefinition(
 )
 
 /**
- * A HUD layer declared by a `.ui.kts` script. It draws into the vanilla HUD at [anchor]/[placement],
- * which is how a scripted layer can land under the crosshair but over the hotbar without the engine
- * owning a fixed list of slots.
+ * A HUD layer declared by a `.ui.kts` script. It draws where its [anchors] put it: into the vanilla HUD
+ * next to a layer, which is how a scripted layer can land under the crosshair but over the hotbar
+ * without the engine owning a fixed list of slots, or on top of the screens it names.
  *
  * [autoShow] layers appear as soon as the script loads, the right default for client-side HUD like
  * a "look at a block to see its name" hint, which has no reason to involve the server. [input] opts
@@ -70,16 +70,28 @@ class UiScreenDefinition(
  */
 class UiOverlayDefinition(
     val id: ResourceLocation,
-    val anchor: ResourceLocation,
-    val placement: HudPlacement,
+    /** In priority; see [OverlayAnchor]. At most one of them is in the HUD. */
+    val anchors: List,
     val autoShow: Boolean,
     val input: OverlayInput,
-    val aboveScreens: Boolean,
     val rebuildEveryFrame: Boolean,
     val exitDuration: Long,
     val content: UiContent,
 ) {
+    init {
+        require(anchors.isNotEmpty()) { "UI overlay '$id' has no anchor" }
+        require(anchors.count { it is OverlayAnchor.HudLayer } <= 1) { "UI overlay '$id' has more than one HUD anchor" }
+    }
+
     val isInteractive: Boolean get() = input == OverlayInput.INTERACTIVE
+
+    private val hudLayer = anchors.firstNotNullOfOrNull { it as? OverlayAnchor.HudLayer }
+
+    /** Where the overlay draws while a screen of [screen] is open, or with no screen if it is null; null if nowhere. */
+    fun anchorFor(screen: Class<*>?): OverlayAnchor? {
+        if (screen == null) return hudLayer
+        return anchors.firstOrNull { it is OverlayAnchor.OnScreen && it.screens.matches(screen) } ?: hudLayer
+    }
 }
 
 /**
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/text/FormattedText.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/text/FormattedText.kt
similarity index 99%
rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/text/FormattedText.kt
rename to runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/text/FormattedText.kt
index 00eb1e6a1..011f943ca 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/dialogue/text/FormattedText.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/ui/text/FormattedText.kt
@@ -1,4 +1,4 @@
-package ru.hollowhorizon.hollowengine.common.dialogue.text
+package ru.hollowhorizon.hollowengine.common.ui.text
 
 /**
  * Parsed dialogue markup. Markup stays a string in the network contract, while this model gives
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/Spelling.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/Spelling.kt
new file mode 100644
index 000000000..ba82165fd
--- /dev/null
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/Spelling.kt
@@ -0,0 +1,38 @@
+package ru.hollowhorizon.hollowengine.common.utils
+
+/** Names that look like a mistyped one, to offer in its place. */
+object Spelling {
+    /** The Levenshtein distance between [left] and [right]. */
+    fun distance(left: String, right: String): Int {
+        if (left == right) return 0
+        var previous = IntArray(right.length + 1) { it }
+        var current = IntArray(right.length + 1)
+        for (i in 1..left.length) {
+            current[0] = i
+            for (j in 1..right.length) {
+                val substitution = previous[j - 1] + if (left[i - 1] == right[j - 1]) 0 else 1
+                current[j] = minOf(current[j - 1] + 1, previous[j] + 1, substitution)
+            }
+            val swap = previous
+            previous = current
+            current = swap
+        }
+        return previous[right.length]
+    }
+
+    /**
+     * Up to [limit] of [names] that [word] may have meant, the closest first: those a few edits away, ignoring case,
+     * and those that hold it or that it holds.
+     */
+    fun similar(word: String, names: Iterable, limit: Int = 3): List {
+        val typed = word.lowercase()
+        val allowed = (typed.length / 3).coerceAtLeast(1)
+        return names.asSequence()
+            .map { it to distance(typed, it.lowercase()) }
+            .filter { (name, distance) -> distance <= allowed || typed.isNotEmpty() && (typed in name.lowercase() || name.lowercase() in typed) }
+            .sortedBy { (_, distance) -> distance }
+            .take(limit)
+            .map { (name, _) -> name }
+            .toList()
+    }
+}
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Declarations.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Declarations.kt
index b86507b4e..2a5e8a9d4 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Declarations.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Declarations.kt
@@ -50,6 +50,13 @@ interface Literals {
     fun list(items: List): Any?
 }
 
+/** A [value] of [type] as the dialect writes it: a primitive the way its literal would be, anything else as it is. */
+internal fun Literals.wrap(type: ExprType, value: Any?): Any? = when (type) {
+    ExprType.Primitive.BOOL -> bool(Coercions.toBool(value))
+    ExprType.Primitive.FLOAT -> number(Coercions.toFloat(value))
+    else -> value
+}
+
 @DslMarker
 annotation class ExpressionDsl
 
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Expression.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Expression.kt
index d9c3ca426..e6d07a3a5 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Expression.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Expression.kt
@@ -45,11 +45,7 @@ class Baked internal constructor(
         val value = ir?.let(Interpreter::objects) ?: return ObjectExpression { null }
         val type = ir.type
         if (literals == null || !type.isPrimitive) return value
-        return if (type === ExprType.Primitive.BOOL) {
-            ObjectExpression { literals.bool(Coercions.toBool(value.eval(it))) }
-        } else {
-            ObjectExpression { literals.number(Coercions.toFloat(value.eval(it))) }
-        }
+        return ObjectExpression { literals.wrap(type, value.eval(it)) }
     }
 
     /** Throws with every diagnostic attached; for callers that treat a bad expression as fatal. */
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Resolver.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Resolver.kt
index eff347d47..c773f2995 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Resolver.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/expressions/Resolver.kt
@@ -24,11 +24,15 @@ internal class Resolver(
         is Ast.Sequence -> resolveSequence(ast)
     }
 
-    private fun resolveList(ast: Ast.ListLit): Ir? = Ir.ListOf(
-        ast.items.map { resolve(it) ?: return null },
-        literals?.type ?: LIST,
-        literals?.let { { items: List -> it.list(items) } },
-    )
+    /** A list; under [literals] an item worked out as a primitive, such as `-60`, is wrapped like a literal. */
+    private fun resolveList(ast: Ast.ListLit): Ir? {
+        val items = ast.items.map { resolve(it) ?: return null }
+        val literals = literals ?: return Ir.ListOf(items, LIST)
+        val types = items.map { it.type }
+        return Ir.ListOf(items, literals.type) { values ->
+            literals.list(values.mapIndexed { index, value -> literals.wrap(types[index], value) })
+        }
+    }
 
     private fun resolveName(ast: Ast.Name): Ir? {
         declarations.root(ast.name)?.let { return Ir.FieldRead(null, it) }
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt
index bc5196760..99d22ce6b 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/NBTReader.kt
@@ -95,6 +95,8 @@ private sealed class AbstractNBTReader(val format: NBTFormat, open val map: Tag)
 
     override fun decodeTaggedTag(key: String): Tag = getValue(key)
 
+    override fun rootTag(): Tag = map
+
     private inline fun  getNumberValue(
         tag: String,
         getter: NumericTag.() -> T,
diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt
index 1779e0ee8..ea217e457 100644
--- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt
+++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/utils/nbt/SpecificEncoders.kt
@@ -14,8 +14,11 @@ internal abstract class NamedValueTagEncoder : NamedValueEncoder(), ICanEncodeTa
 
 @OptIn(InternalSerializationApi::class)
 internal abstract class NamedValueNbtDecoder : NamedValueDecoder(), ICanDecodeTag {
-    final override fun decodeTag(): Tag = decodeTaggedTag(popTag())
+    final override fun decodeTag(): Tag = if (currentTagOrNull == null) rootTag() else decodeTaggedTag(popTag())
     abstract fun decodeTaggedTag(key: String): Tag
+
+    /** The tag being read, for a value read as a whole tag at the top level, where it has no name. */
+    abstract fun rootTag(): Tag
 }
 
 internal interface ICanEncodeTag : ICanEncodeCompoundNBT {
diff --git a/runtime/src/main/lang/en_us/hollowengine/commands.json b/runtime/src/main/lang/en_us/hollowengine/commands.json
index e85f6988f..4c2fb8e91 100644
--- a/runtime/src/main/lang/en_us/hollowengine/commands.json
+++ b/runtime/src/main/lang/en_us/hollowengine/commands.json
@@ -7,7 +7,5 @@
   "model_no_animation": "Model %s has no animation '%s'. Its animations: %s",
   "tags": "Β§6[Β§bHollow EngineΒ§6] Β§7Item tags: ",
   "scripting_state_requires_node_script": "Cannot use an initial state with '%s': states are supported only by .node.kts scripts. Use a .node.kts script or run the .kts script without the state argument.",
-  "expected_script_path": "Expected a script path, for example scripts/nodes/example.node.kts or my-addon:nodes/example.node.kts",
-  "dialogue_not_in_dialogue": "You are not taking part in a dialogue right now.",
-  "dialogue_no_such_option": "There is no option %s in the dialogue you are in."
+  "expected_script_path": "Expected a script path, for example scripts/nodes/example.node.kts or my-addon:nodes/example.node.kts"
 }
diff --git a/runtime/src/main/lang/en_us/hollowengine/dialogue.json b/runtime/src/main/lang/en_us/hollowengine/dialogue.json
deleted file mode 100644
index ca0e2ef69..000000000
--- a/runtime/src/main/lang/en_us/hollowengine/dialogue.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "advance": "[ continue β–Ά ]"
-}
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/animator_editor.json b/runtime/src/main/lang/en_us/hollowengine/gui/animator_editor.json
index 17546ee3e..ac0fa72b4 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/animator_editor.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/animator_editor.json
@@ -7,6 +7,7 @@
   "reset_view": "Reset view",
   "parameters": "Parameters",
   "no_graph": "This layer has no graph; its parameters are in the inspector.",
+  "broken_file": "The file could not be read, so it is open read-only:",
   "no_layers": "No layers",
   "nothing_selected": "Nothing selected",
   "layer_removed": "Layer removed",
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/console.json b/runtime/src/main/lang/en_us/hollowengine/gui/console.json
index f05db6436..fd194b180 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/console.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/console.json
@@ -1,4 +1,5 @@
 {
+  "clear": "Clear all entries",
   "command_hint": "Enter a command...",
   "kotlin_hint": "Kotlin: player, level, server. Ctrl+Enter runs it",
   "no_world": "No world: switch to Kotlin to run code here",
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/graph.json b/runtime/src/main/lang/en_us/hollowengine/gui/graph.json
index a04a93903..51984678c 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/graph.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/graph.json
@@ -8,5 +8,22 @@
   "align.center_y": "Center vertically",
   "align.bottom": "Align bottom edges",
   "distribute.horizontal": "Space evenly across",
-  "distribute.vertical": "Space evenly down"
-}
+  "distribute.vertical": "Space evenly down",
+  "editor.duplicate": "Duplicate",
+  "editor.delete_node": "Delete node",
+  "editor.delete_link": "Delete link",
+  "editor.reroute": "Reroute",
+  "editor.group": "Group",
+  "editor.group_nodes": "Group",
+  "editor.ungroup": "Ungroup",
+  "editor.leave_group": "Take out of the group",
+  "editor.collapse_group": "Collapse into one node",
+  "editor.expand_group": "Expand",
+  "editor.delete_group_nodes": "Delete with the nodes",
+  "editor.search": "Find a node",
+  "editor.search_none": "Nothing found",
+  "editor.search_previous": "Previous",
+  "editor.search_next": "Next",
+  "editor.search_close": "Close",
+  "editor.search_items": "Search"
+}
\ No newline at end of file
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/history.json b/runtime/src/main/lang/en_us/hollowengine/gui/history.json
index 5b6112a66..3530db1bc 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/history.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/history.json
@@ -8,6 +8,7 @@
   "world": "World",
   "timeline": "Timeline",
   "edit": "Edit",
+  "code.rename": "Rename %s to %s",
   "held_edit": "Edit at the playhead",
   "typing": "Typing",
   "stroke": "Brush stroke",
@@ -32,7 +33,5 @@
   "timeline.curve_preset": "Apply curve preset",
   "timeline.rotation_basis": "Change rotation basis",
   "timeline.record_keys": "Record keys",
-  "timeline.capture_key": "Capture keyframe",
-  "timeline.move_cutscene": "Move cutscene",
-  "timeline.reanchor_cutscene": "Re-anchor cutscene"
+  "timeline.capture_key": "Capture keyframe"
 }
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/editor.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/editor.json
index eef61475f..35200915d 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/editor.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/editor.json
@@ -15,6 +15,12 @@
   "status.changed_while_formatting": "%s changed while formatting",
   "status.reformatted": "Reformatted %s",
   "status.definition_not_found": "Definition not found",
+  "rename.title": "Rename %s",
+  "rename.unavailable": "Nothing here can be renamed: put the caret on a name declared in the project",
+  "rename.invalid": "\"%s\" is not a Kotlin name",
+  "rename.nothing": "Nothing refers to %s",
+  "rename.done": "Renamed %s places in %s files",
+  "rename.partly": "Renamed %s places in %s files; %s files changed meanwhile and were left as they are",
   "status.resource_not_found": "Cannot find '%s'",
   "status.unsupported_action": "This action is not supported",
   "status.unsupported_definition": "Cannot open the definition in %s",
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/file.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/file.json
index e05186426..bde85a261 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/file.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/file.json
@@ -1,6 +1,7 @@
 {
   "already_exists": "A file by that name is already there",
   "deleted": "Deleted: %s",
+  "external_change_discarded": "%s changed outside the IDE: your unsaved edits were replaced by it",
   "save_failed": "Could not save %s, see the log",
   "invalid_name": "That file name cannot be used",
   "no_permissions_reload_server": "You don't have permissions to reload server resources!",
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json
index e79a2acdf..99cc1be6f 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gizmo.json
@@ -6,5 +6,6 @@
   "orientation.global": "global",
   "orientation.local": "local",
   "orientation.view": "view",
-  "orbit_selection": "Orbit around the selection instead of the view's center"
+  "rotation.view_aligned": "View-aligned rotation Β· Switch to trackball",
+  "rotation.trackball": "Trackball rotation Β· Switch to view-aligned"
 }
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json
index 824985a8d..e275d1ecd 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/gutter.json
@@ -4,6 +4,14 @@
   "override": "Go to overridden declaration",
   "coroutine": "Suspend function call",
   "event": "Event handler",
+  "stage": "Stage %s: once it returns, a restart continues below this line",
+  "checkpoint": "Checkpoint %1$s: rewind(\"%1$s\") returns here, and a restart continues below",
+  "rewind": "rewind() goes back to checkpoint %s",
+  "rewind_start": "rewind() goes back to where this run started: no checkpoint precedes it",
+  "temporary": "Taken back at checkpoint %s, or when the story ends",
+  "temporary_open": "Taken back at the next checkpoint this run passes, or when the story ends",
+  "action": "Reversible action: the story journal can undo it",
+  "action_call": "Action %s: once it is done, a restart continues below this line; one the restart interrupts is finished",
   "run": "Run script",
   "run_state": "Run state %s",
   "stop": "Stop script",
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/project.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/project.json
index 704ddd5fb..5779ea403 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/project.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/project.json
@@ -1,4 +1,5 @@
 {
+  "copy_path": "Copy Path",
   "settings": "Project settings",
   "settings.title": "Project settings",
   "settings.saved": "Project settings saved",
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/timeline.json
similarity index 78%
rename from runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json
rename to runtime/src/main/lang/en_us/hollowengine/gui/ide/timeline.json
index ebe25a6ba..3dbb428d8 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/timeline.json
@@ -27,34 +27,14 @@
   "action.load": "Load…",
   "action.cancel": "Cancel",
   "action.close": "Close",
-  "dialog.save_title": "Save cutscene",
-  "dialog.load_title": "Load cutscene",
-  "dialog.name": "Name",
-  "dialog.no_cutscenes": "No saved cutscenes",
-  "section.preview": "Preview",
-  "section.origin": "Origin",
   "section.keyframe": "Keyframe",
   "section.keyframes": "%s Keyframes",
   "section.interpolation": "Interpolation",
   "section.work_area": "Work Area",
   "section.selection": "Selection",
-  "preview.camera_on": "Camera On",
-  "preview.camera_off": "Camera Off",
-  "preview.playing": "Playing",
-  "preview.paused": "Paused",
-  "preview.current_time": "Current time",
-  "preview.duration": "Duration",
-  "origin.x": "Origin X",
-  "origin.y": "Origin Y",
-  "origin.z": "Origin Z",
-  "origin.yaw": "Yaw",
-  "origin.move_here": "Move here",
-  "origin.rebase": "Re-anchor",
   "keyframe.time": "Time",
   "keyframe.value": "Value",
   "keyframe.channel": "Channel",
-  "keyframe.world": "World",
-  "keyframe.world_rotation": "World rotation",
   "keyframe.delete_key": "Delete key",
   "keyframe.delete_keys": "Delete keys",
   "keyframe.no_selection": "No keyframe selected",
@@ -70,9 +50,6 @@
   "rotation.mode": "Rotation basis",
   "rotation.euler": "Euler",
   "rotation.quaternion": "Quaternion",
-  "weather.clear": "Clear",
-  "weather.rain": "Rain",
-  "weather.thunder": "Thunder",
   "track.select_keys": "Select its keys",
   "track.show": "Turn on: the track drives its value again",
   "track.hide": "Turn off: the track keeps its keys but no longer drives its value",
@@ -88,5 +65,6 @@
   "action.zoom_out": "Zoom out (Ctrl+wheel)",
   "action.more": "More",
   "view.frame_time": "Fit the work area (F)",
-  "preview.time_hint": "Where the playhead is; type a time to jump there"
+  "preview.time_hint": "Where the playhead is; type a time to jump there",
+  "empty": "Open a montage, an effect or another file with a timeline to edit it here."
 }
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json b/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json
index e46747c49..9ec6c20db 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/keymap.json
@@ -54,6 +54,7 @@
   "action.timeline.next_key": "Next key",
   "action.timeline.previous_key": "Previous key",
   "action.timeline.smooth_keys": "Smooth the selected keys",
+  "action.timeline.select_key_columns": "Select keys at the playhead or selected times",
   "action.timeline.nudge_left": "Nudge back",
   "action.timeline.nudge_right": "Nudge forward",
   "action.timeline.nudge_left_far": "Nudge back further",
@@ -87,8 +88,9 @@
   "action.viewport.orbit_up": "Orbit up",
   "action.viewport.orbit_down": "Orbit down",
   "action.viewport.view_selected": "Center the view on the selection",
-  "action.viewport.orbit_pivot": "Orbit around the selection or the center",
   "action.graph.add_node": "Add a node",
+  "action.graph.next_found": "Next node found",
+  "action.graph.previous_found": "Previous node found",
   "action.graph.group": "Group the selection",
   "action.graph.ungroup": "Ungroup",
   "action.graph.entry_state": "Make the state the entry"
diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json b/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json
index 525df34e1..015658ffc 100644
--- a/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json
+++ b/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json
@@ -35,18 +35,8 @@
   "expression.unclosed": "A parenthesis is not closed",
   "expression.unknown_function": "No such function",
   "expression.reserved_name": "This name is taken by GLSL or the engine",
-  "duplicate": "Duplicate",
-  "delete_node": "Delete node",
-  "delete_link": "Delete link",
-  "reroute": "Reroute",
   "reroute_hint": "A point a link passes through, to lead it around other nodes. Drag it to move it, Alt and drag to pull another link out of it, double-click a link to put one on it. Deleting it joins the link back up.",
   "group": "Group",
-  "group_nodes": "Group",
-  "ungroup": "Ungroup",
-  "leave_group": "Take out of the group",
-  "collapse_group": "Collapse into one node",
-  "expand_group": "Expand",
-  "delete_group_nodes": "Delete with the nodes",
   "group_title": "Title",
   "group_color": "Color",
   "color.gray": "Gray",
@@ -69,6 +59,7 @@
   "no_problems": "The graph compiles.",
   "problem.unknown_node": "Unknown node",
   "problem.unknown_pin": "Unknown pin",
+  "problem.missing_node": "A link leads to a node the graph does not have",
   "problem.no_output_node": "The graph has no output node",
   "problem.second_output_node": "Only the first output node is used",
   "problem.cycle": "The node is on a loop and is left out",
@@ -156,4 +147,4 @@
   "node.ray.plane": "Ray and Plane",
   "node.ray.sphere": "Ray and Sphere",
   "node.ray.screen_position": "Point on Screen"
-}
+}
\ No newline at end of file
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/commands.json b/runtime/src/main/lang/ru_ru/hollowengine/commands.json
index f0c7438a7..6ac0d4e6f 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/commands.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/commands.json
@@ -7,7 +7,5 @@
   "model_no_animation": "Π’ ΠΌΠΎΠ΄Π΅Π»ΠΈ %s Π½Π΅Ρ‚ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ '%s'. Π•Ρ‘ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ: %s",
   "tags": "Β§6[Β§bHollow EngineΒ§6] Β§7Π’Π΅Π³ΠΈ ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚Π°: ",
   "scripting_state_requires_node_script": "НСльзя ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‚ΡŒ Π½Π°Ρ‡Π°Π»ΡŒΠ½Ρ‹ΠΉ стСйт скрипту '%s': стСйты ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°ΡŽΡ‚ΡΡ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ скриптами .node.kts. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ скрипт .node.kts Π»ΠΈΠ±ΠΎ запуститС .kts Π±Π΅Π· Π°Ρ€Π³ΡƒΠΌΠ΅Π½Ρ‚Π° стСйта.",
-  "expected_script_path": "ОТидался ΠΏΡƒΡ‚ΡŒ ΠΊ скрипту, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ scripts/nodes/example.node.kts ΠΈΠ»ΠΈ my-addon:nodes/example.node.kts",
-  "dialogue_not_in_dialogue": "БСйчас Π²Ρ‹ Π½Π΅ участвуСтС Π² Π΄ΠΈΠ°Π»ΠΎΠ³Π΅.",
-  "dialogue_no_such_option": "Π’ вашСм Π΄ΠΈΠ°Π»ΠΎΠ³Π΅ Π½Π΅Ρ‚ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π° %s."
+  "expected_script_path": "ОТидался ΠΏΡƒΡ‚ΡŒ ΠΊ скрипту, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ scripts/nodes/example.node.kts ΠΈΠ»ΠΈ my-addon:nodes/example.node.kts"
 }
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/dialogue.json b/runtime/src/main/lang/ru_ru/hollowengine/dialogue.json
deleted file mode 100644
index 171a10976..000000000
--- a/runtime/src/main/lang/ru_ru/hollowengine/dialogue.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "advance": "[ дальшС β–Ά ]"
-}
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/animator_editor.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/animator_editor.json
index 520b6e548..64adcc6ef 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/animator_editor.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/animator_editor.json
@@ -7,6 +7,7 @@
   "reset_view": "Π‘Π±Ρ€ΠΎΡΠΈΡ‚ΡŒ Π²ΠΈΠ΄",
   "parameters": "ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹",
   "no_graph": "Π£ этого слоя Π½Π΅Ρ‚ Π³Ρ€Π°Ρ„Π°, Π΅Π³ΠΎ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹ Π² инспСкторС.",
+  "broken_file": "Π€Π°ΠΉΠ» Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Ρ‚ΡŒ, поэтому ΠΎΠ½ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для чтСния:",
   "no_layers": "Π‘Π»ΠΎΡ‘Π² Π½Π΅Ρ‚",
   "nothing_selected": "НичСго Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½ΠΎ",
   "layer_removed": "Π‘Π»ΠΎΠΉ ΡƒΠ΄Π°Π»Ρ‘Π½",
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/console.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/console.json
index d655c36ce..01f8133c0 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/console.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/console.json
@@ -1,4 +1,5 @@
 {
+  "clear": "ΠžΡ‡ΠΈΡΡ‚ΠΈΡ‚ΡŒ всС записи",
   "command_hint": "Π’Π²Π΅Π΄ΠΈΡ‚Π΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ...",
   "kotlin_hint": "Kotlin: player, level, server. Ctrl+Enter - запуск",
   "no_world": "НСт ΠΌΠΈΡ€Π°: ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡ΠΈΡ‚Π΅ΡΡŒ Π½Π° Kotlin, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π·Π°ΠΏΡƒΡΠΊΠ°Ρ‚ΡŒ ΠΊΠΎΠ΄",
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/graph.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/graph.json
index 5c4d16043..21c7b6168 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/graph.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/graph.json
@@ -8,5 +8,22 @@
   "align.center_y": "По Ρ†Π΅Π½Ρ‚Ρ€Ρƒ ΠΏΠΎ Π²Π΅Ρ€Ρ‚ΠΈΠΊΠ°Π»ΠΈ",
   "align.bottom": "По Π½ΠΈΠΆΠ½Π΅ΠΌΡƒ ΠΊΡ€Π°ΡŽ",
   "distribute.horizontal": "Π Π°Π²Π½ΠΎΠΌΠ΅Ρ€Π½ΠΎ ΠΏΠΎ Π³ΠΎΡ€ΠΈΠ·ΠΎΠ½Ρ‚Π°Π»ΠΈ",
-  "distribute.vertical": "Π Π°Π²Π½ΠΎΠΌΠ΅Ρ€Π½ΠΎ ΠΏΠΎ Π²Π΅Ρ€Ρ‚ΠΈΠΊΠ°Π»ΠΈ"
-}
+  "distribute.vertical": "Π Π°Π²Π½ΠΎΠΌΠ΅Ρ€Π½ΠΎ ΠΏΠΎ Π²Π΅Ρ€Ρ‚ΠΈΠΊΠ°Π»ΠΈ",
+  "editor.duplicate": "Π”ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
+  "editor.delete_node": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΡƒΠ·Π΅Π»",
+  "editor.delete_link": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ связь",
+  "editor.reroute": "Π’ΠΎΡ‡ΠΊΠ° пСрСнаправлСния",
+  "editor.group": "Π“Ρ€ΡƒΠΏΠΏΠ°",
+  "editor.group_nodes": "Π‘Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
+  "editor.ungroup": "Π Π°Π·Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
+  "editor.leave_group": "Π£Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· Π³Ρ€ΡƒΠΏΠΏΡ‹",
+  "editor.collapse_group": "Π‘Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ Π² ΠΎΠ΄Π½Ρƒ Π½ΠΎΠ΄Ρƒ",
+  "editor.expand_group": "Π Π°Π·Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ",
+  "editor.delete_group_nodes": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ вмСстС с Π½ΠΎΠ΄Π°ΠΌΠΈ",
+  "editor.search": "Найти Π½ΠΎΠ΄Ρƒ",
+  "editor.search_none": "НичСго Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ",
+  "editor.search_previous": "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π°Ρ",
+  "editor.search_next": "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ",
+  "editor.search_close": "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ",
+  "editor.search_items": "Поиск"
+}
\ No newline at end of file
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json
index 3bc768202..aaf84f89b 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/history.json
@@ -8,6 +8,7 @@
   "world": "ΠœΠΈΡ€",
   "timeline": "Π’Π°ΠΉΠΌΠ»Π°ΠΉΠ½",
   "edit": "ИзмСнСниС",
+  "code.rename": "ΠŸΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΈΠ΅ %s Π² %s",
   "held_edit": "ΠŸΡ€Π°Π²ΠΊΠ° Π½Π° ΠΊΠ°Π΄Ρ€Π΅",
   "typing": "Π’Π²ΠΎΠ΄ тСкста",
   "stroke": "Мазок ΠΊΠΈΡΡ‚ΡŒΡŽ",
@@ -32,7 +33,5 @@
   "timeline.curve_preset": "ΠŸΡ€Π΅ΡΠ΅Ρ‚ ΠΊΡ€ΠΈΠ²ΠΎΠΉ",
   "timeline.rotation_basis": "Π‘ΠΌΠ΅Π½Π° базиса вращСния",
   "timeline.record_keys": "Π—Π°ΠΏΠΈΡΡŒ ΠΊΠ»ΡŽΡ‡Π΅ΠΉ",
-  "timeline.capture_key": "Π—Π°Ρ…Π²Π°Ρ‚ ΠΊΠ»ΡŽΡ‡Π°",
-  "timeline.move_cutscene": "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠ΅ катсцСны",
-  "timeline.reanchor_cutscene": "ΠŸΠ΅Ρ€Π΅ΠΏΡ€ΠΈΠ²ΡΠ·ΠΊΠ° катсцСны"
+  "timeline.capture_key": "Π—Π°Ρ…Π²Π°Ρ‚ ΠΊΠ»ΡŽΡ‡Π°"
 }
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/editor.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/editor.json
index 75b139106..50c8a9a47 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/editor.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/editor.json
@@ -15,6 +15,12 @@
   "status.changed_while_formatting": "Β«%sΒ» измСнился Π²ΠΎ врСмя форматирования",
   "status.reformatted": "Β«%sΒ» ΠΎΡ‚Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½",
   "status.definition_not_found": "ΠžΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ",
+  "rename.title": "ΠŸΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Ρ‚ΡŒ %s",
+  "rename.unavailable": "Π—Π΄Π΅ΡΡŒ Π½Π΅Ρ‡Π΅Π³ΠΎ ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Ρ‚ΡŒ: ΠΏΠΎΡΡ‚Π°Π²ΡŒΡ‚Π΅ ΠΊΠ°Ρ€Π΅Ρ‚ΠΊΡƒ Π½Π° имя, объявлСнноС Π² ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π΅",
+  "rename.invalid": "Β«%sΒ» Π½Π΅ годится ΠΊΠ°ΠΊ имя Π² Kotlin",
+  "rename.nothing": "На %s Π½ΠΈΡ‡Ρ‚ΠΎ Π½Π΅ ссылаСтся",
+  "rename.done": "ΠŸΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΎ мСст: %s, Ρ„Π°ΠΉΠ»ΠΎΠ²: %s",
+  "rename.partly": "ΠŸΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΎ мСст: %s, Ρ„Π°ΠΉΠ»ΠΎΠ²: %s; Ρ„Π°ΠΉΠ»ΠΎΠ² измСнилось Π·Π° это врСмя ΠΈ Π½Π΅ Ρ‚Ρ€ΠΎΠ½ΡƒΡ‚ΠΎ: %s",
   "status.resource_not_found": "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π½Π°ΠΉΡ‚ΠΈ Β«%sΒ»",
   "status.unsupported_action": "Π­Ρ‚ΠΎ дСйствиС Π½Π΅ поддСрТиваСтся",
   "status.unsupported_definition": "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Π² %s",
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/file.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/file.json
index e45bc4f46..c17cf0670 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/file.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/file.json
@@ -1,6 +1,7 @@
 {
   "already_exists": "Π€Π°ΠΉΠ» с Ρ‚Π°ΠΊΠΈΠΌ ΠΈΠΌΠ΅Π½Π΅ΠΌ ΡƒΠΆΠ΅ Π΅ΡΡ‚ΡŒ",
   "deleted": "Π£Π΄Π°Π»Π΅Π½ΠΎ: %s",
+  "external_change_discarded": "%s ΠΈΠ·ΠΌΠ΅Π½Ρ‘Π½ Π²Π½Π΅ IDE: нСсохранённыС ΠΏΡ€Π°Π²ΠΊΠΈ Π·Π°ΠΌΠ΅Π½Π΅Π½Ρ‹ Π΅Π³ΠΎ содСрТимым",
   "save_failed": "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ %s, подробности Π² Π»ΠΎΠ³Π΅",
   "invalid_name": "Π’Π°ΠΊΠΎΠ΅ имя Ρ„Π°ΠΉΠ»Π° ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ нСльзя",
   "no_permissions_reload_server": "Π£ вас Π½Π΅Ρ‚ ΠΏΡ€Π°Π² для ΠΏΠ΅Ρ€Π΅Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ сСрвСрных рСсурсов!",
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json
index 508b92a5a..5943cd30b 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gizmo.json
@@ -6,5 +6,6 @@
   "orientation.global": "Π³Π»ΠΎΠ±Π°Π»ΡŒΠ½Ρ‹Π΅",
   "orientation.local": "Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅",
   "orientation.view": "Π²ΠΈΠ΄Π°",
-  "orbit_selection": "Π’Ρ€Π°Ρ‰Π°Ρ‚ΡŒ Π²ΠΈΠ΄ Π²ΠΎΠΊΡ€ΡƒΠ³ Π²Ρ‹Π΄Π΅Π»Π΅Π½Π½ΠΎΠ³ΠΎ, Π° Π½Π΅ Π²ΠΎΠΊΡ€ΡƒΠ³ Ρ†Π΅Π½Ρ‚Ρ€Π°"
+  "rotation.view_aligned": "Π’Ρ€Π°Ρ‰Π΅Π½ΠΈΠ΅ Π² плоскости Π²ΠΈΠ΄Π° | ΠŸΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π½Π° Ρ‚Ρ€Π΅ΠΊΠ±ΠΎΠ»",
+  "rotation.trackball": "Π’Ρ€Π΅ΠΊΠ±ΠΎΠ»-Π²Ρ€Π°Ρ‰Π΅Π½ΠΈΠ΅ | ΠŸΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π² ΠΏΠ»ΠΎΡΠΊΠΎΡΡ‚ΡŒ Π²ΠΈΠ΄Π°"
 }
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json
index 4d26aa641..f78ce56f9 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/gutter.json
@@ -4,6 +4,14 @@
   "override": "ΠŸΠ΅Ρ€Π΅ΠΉΡ‚ΠΈ ΠΊ ΠΏΠ΅Ρ€Π΅ΠΎΠΏΡ€Π΅Π΄Π΅Π»Ρ‘Π½Π½ΠΎΠΉ Π΄Π΅ΠΊΠ»Π°Ρ€Π°Ρ†ΠΈΠΈ",
   "coroutine": "Π’Ρ‹Π·ΠΎΠ² suspend-Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ",
   "event": "ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ события",
+  "stage": "Бтадия %s: ΠΊΠΎΠ³Π΄Π° ΠΎΠ½Π° вСрнётся, рСстарт ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ Π½ΠΈΠΆΠ΅ этой строки",
+  "checkpoint": "Π§Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚ %1$s: rewind(\"%1$s\") Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ сюда, рСстарт ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅Ρ‚ Π½ΠΈΠΆΠ΅",
+  "rewind": "rewind() Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΊ Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Ρƒ %s",
+  "rewind_start": "rewind() Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΊ Π½Π°Ρ‡Π°Π»Ρƒ этого ΠΏΡ€ΠΎΠ³ΠΎΠ½Π°: Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚ΠΎΠ² ΠΏΠ΅Ρ€Π΅Π΄ Π½ΠΈΠΌ Π½Π΅Ρ‚",
+  "temporary": "БнимаСтся Π½Π° Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Π΅ %s ΠΈΠ»ΠΈ Π² ΠΊΠΎΠ½Ρ†Π΅ истории",
+  "temporary_open": "БнимаСтся Π½Π° ΡΠ»Π΅Π΄ΡƒΡŽΡ‰Π΅ΠΌ Ρ‡Π΅ΠΊΠΏΠΎΠΈΠ½Ρ‚Π΅ этого ΠΏΡ€ΠΎΠ³ΠΎΠ½Π° ΠΈΠ»ΠΈ Π² ΠΊΠΎΠ½Ρ†Π΅ истории",
+  "action": "ΠžΠ±Ρ€Π°Ρ‚ΠΈΠΌΠΎΠ΅ дСйствиС: ΠΆΡƒΡ€Π½Π°Π» истории ΠΌΠΎΠΆΠ΅Ρ‚ Π΅Π³ΠΎ ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ",
+  "action_call": "ДСйствиС %s: ΠΊΠΎΠ³Π΄Π° ΠΎΠ½ΠΎ закончится, рСстарт ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ Π½ΠΈΠΆΠ΅ этой строки; ΠΏΡ€Π΅Ρ€Π²Π°Π½Π½ΠΎΠ΅ рСстартом - ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ дСйствиС",
   "run": "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ скрипт",
   "run_state": "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ состояниС %s",
   "stop": "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ скрипт",
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/project.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/project.json
index 62e8d7fbe..83195a00a 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/project.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/project.json
@@ -1,4 +1,5 @@
 {
+  "copy_path": "Π‘ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΏΡƒΡ‚ΡŒ",
   "settings": "Настройки ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°",
   "settings.title": "Настройки ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°",
   "settings.saved": "Настройки ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° сохранСны",
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/timeline.json
similarity index 78%
rename from runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json
rename to runtime/src/main/lang/ru_ru/hollowengine/gui/ide/timeline.json
index 5583c6af8..b7a60d866 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/timeline.json
@@ -27,34 +27,14 @@
   "action.load": "Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒβ€¦",
   "action.cancel": "ΠžΡ‚ΠΌΠ΅Π½Π°",
   "action.close": "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ",
-  "dialog.save_title": "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ катсцСны",
-  "dialog.load_title": "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° катсцСны",
-  "dialog.name": "НазваниС",
-  "dialog.no_cutscenes": "Π‘ΠΎΡ…Ρ€Π°Π½Ρ‘Π½Π½Ρ‹Ρ… катсцСн Π½Π΅Ρ‚",
-  "section.preview": "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€",
-  "section.origin": "Начало ΠΊΠΎΠΎΡ€Π΄ΠΈΠ½Π°Ρ‚",
   "section.keyframe": "ΠšΠ»ΡŽΡ‡Π΅Π²ΠΎΠΉ ΠΊΠ°Π΄Ρ€",
   "section.keyframes": "ΠšΠ»ΡŽΡ‡Π΅Π²Ρ‹Ρ… ΠΊΠ°Π΄Ρ€ΠΎΠ²: %s",
   "section.interpolation": "Π˜Π½Ρ‚Π΅Ρ€ΠΏΠΎΠ»ΡΡ†ΠΈΡ",
   "section.work_area": "Рабочая ΠΎΠ±Π»Π°ΡΡ‚ΡŒ",
   "section.selection": "Π’Ρ‹Π΄Π΅Π»Π΅Π½ΠΈΠ΅",
-  "preview.camera_on": "ΠšΠ°ΠΌΠ΅Ρ€Π° Π²ΠΊΠ»",
-  "preview.camera_off": "ΠšΠ°ΠΌΠ΅Ρ€Π° Π²Ρ‹ΠΊΠ»",
-  "preview.playing": "ВоспроизвСдСниС",
-  "preview.paused": "ΠŸΠ°ΡƒΠ·Π°",
-  "preview.current_time": "Π’Π΅ΠΊΡƒΡ‰Π΅Π΅ врСмя",
-  "preview.duration": "Π”Π»ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ",
-  "origin.x": "Начало X",
-  "origin.y": "Начало Y",
-  "origin.z": "Начало Z",
-  "origin.yaw": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚",
-  "origin.move_here": "ΠŸΠ΅Ρ€Π΅Π½Π΅ΡΡ‚ΠΈ сюда",
-  "origin.rebase": "ΠŸΠ΅Ρ€Π΅ΠΏΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ",
   "keyframe.time": "ВрСмя",
   "keyframe.value": "Π—Π½Π°Ρ‡Π΅Π½ΠΈΠ΅",
   "keyframe.channel": "Канал",
-  "keyframe.world": "Π’ ΠΌΠΈΡ€Π΅",
-  "keyframe.world_rotation": "ΠŸΠΎΠ²ΠΎΡ€ΠΎΡ‚ Π² ΠΌΠΈΡ€Π΅",
   "keyframe.delete_key": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠ»ΡŽΡ‡",
   "keyframe.delete_keys": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠ»ΡŽΡ‡ΠΈ",
   "keyframe.no_selection": "ΠšΠ»ΡŽΡ‡Π΅Π²ΠΎΠΉ ΠΊΠ°Π΄Ρ€ Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½",
@@ -70,9 +50,6 @@
   "rotation.mode": "ΠŸΡ€Π΅Π΄ΡΡ‚Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΏΠΎΠ²ΠΎΡ€ΠΎΡ‚Π°",
   "rotation.euler": "Π­ΠΉΠ»Π΅Ρ€",
   "rotation.quaternion": "ΠšΠ²Π°Ρ‚Π΅Ρ€Π½ΠΈΠΎΠ½",
-  "weather.clear": "Ясно",
-  "weather.rain": "Π”ΠΎΠΆΠ΄ΡŒ",
-  "weather.thunder": "Π“Ρ€ΠΎΠ·Π°",
   "track.select_keys": "Π’Ρ‹Π΄Π΅Π»ΠΈΡ‚ΡŒ ΠΊΠ»ΡŽΡ‡ΠΈ",
   "track.show": "Π’ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ: Π΄ΠΎΡ€ΠΎΠΆΠΊΠ° снова управляСт Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ΠΌ",
   "track.hide": "ΠžΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ: ΠΊΠ»ΡŽΡ‡ΠΈ ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ, Π½ΠΎ Π΄ΠΎΡ€ΠΎΠΆΠΊΠ° большС Π½Π΅ управляСт Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ΠΌ",
@@ -88,5 +65,6 @@
   "action.zoom_out": "ΠžΡ‚Π΄Π°Π»ΠΈΡ‚ΡŒ (Ctrl+колСсо)",
   "action.more": "Π•Ρ‰Ρ‘",
   "view.frame_time": "Π’ΠΏΠΈΡΠ°Ρ‚ΡŒ Ρ€Π°Π±ΠΎΡ‡ΡƒΡŽ ΠΎΠ±Π»Π°ΡΡ‚ΡŒ (F)",
-  "preview.time_hint": "Π“Π΄Π΅ стоит курсор; Π²Π²Π΅Π΄ΠΈΡ‚Π΅ врСмя, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠ΅Ρ€Π΅ΠΉΡ‚ΠΈ"
+  "preview.time_hint": "Π“Π΄Π΅ стоит курсор; Π²Π²Π΅Π΄ΠΈΡ‚Π΅ врСмя, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠ΅Ρ€Π΅ΠΉΡ‚ΠΈ",
+  "empty": "ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ ΠΌΠΎΠ½Ρ‚Π°ΠΆ, эффСкт ΠΈΠ»ΠΈ Π΄Ρ€ΡƒΠ³ΠΎΠΉ Ρ„Π°ΠΉΠ» с Ρ‚Π°ΠΉΠΌΠ»Π°ΠΉΠ½ΠΎΠΌ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π΅Π³ΠΎ здСсь."
 }
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json
index 26aec74fd..0313f5fb2 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/keymap.json
@@ -87,8 +87,10 @@
   "action.viewport.orbit_up": "ΠŸΠΎΠ²Π΅Ρ€Π½ΡƒΡ‚ΡŒ Π²ΠΈΠ΄ Π²Π²Π΅Ρ€Ρ…",
   "action.viewport.orbit_down": "ΠŸΠΎΠ²Π΅Ρ€Π½ΡƒΡ‚ΡŒ Π²ΠΈΠ΄ Π²Π½ΠΈΠ·",
   "action.viewport.view_selected": "Π¦Π΅Π½Ρ‚Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π²ΠΈΠ΄ Π½Π° Π²Ρ‹Π΄Π΅Π»Π΅Π½Π½ΠΎΠΌ",
-  "action.viewport.orbit_pivot": "Π’Ρ€Π°Ρ‰Π°Ρ‚ΡŒ Π²ΠΈΠ΄ Π²ΠΎΠΊΡ€ΡƒΠ³ Π²Ρ‹Π΄Π΅Π»Π΅Π½Π½ΠΎΠ³ΠΎ ΠΈΠ»ΠΈ Ρ†Π΅Π½Ρ‚Ρ€Π°",
+  "action.timeline.select_key_columns": "Π’Ρ‹Π΄Π΅Π»ΠΈΡ‚ΡŒ ΠΊΠ»ΡŽΡ‡ΠΈ Π½Π° курсорС ΠΈΠ»ΠΈ ΠΌΠ΅Ρ‚ΠΊΠ°Ρ… Π²Ρ‹Π΄Π΅Π»Π΅Π½Π½Ρ‹Ρ… ΠΊΠ»ΡŽΡ‡Π΅ΠΉ",
   "action.graph.add_node": "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π½ΠΎΠ΄Ρƒ",
+  "action.graph.next_found": "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ найдСнная Π½ΠΎΠ΄Π°",
+  "action.graph.previous_found": "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π°Ρ найдСнная Π½ΠΎΠ΄Π°",
   "action.graph.group": "Π‘Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π²Ρ‹Π΄Π΅Π»Π΅Π½Π½ΠΎΠ΅",
   "action.graph.ungroup": "Π Π°Π·Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
   "action.graph.entry_state": "Π‘Π΄Π΅Π»Π°Ρ‚ΡŒ состояниС Π²Ρ…ΠΎΠ΄Π½Ρ‹ΠΌ"
diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json
index ee6b3d8b2..55daac2da 100644
--- a/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json
+++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json
@@ -35,18 +35,8 @@
   "expression.unclosed": "НС Π·Π°ΠΊΡ€Ρ‹Ρ‚Π° скобка",
   "expression.unknown_function": "НСт Ρ‚Π°ΠΊΠΎΠΉ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ",
   "expression.reserved_name": "Π­Ρ‚ΠΎ имя занято GLSL ΠΈΠ»ΠΈ Π΄Π²ΠΈΠΆΠΊΠΎΠΌ",
-  "duplicate": "Π”ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
-  "delete_node": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΡƒΠ·Π΅Π»",
-  "delete_link": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ связь",
-  "reroute": "Π’ΠΎΡ‡ΠΊΠ° пСрСнаправлСния",
   "reroute_hint": "Π’ΠΎΡ‡ΠΊΠ°, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€ΡƒΡŽ ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ связь, Ρ‡Ρ‚ΠΎΠ±Ρ‹ обвСсти Π΅Ρ‘ Π²ΠΎΠΊΡ€ΡƒΠ³ Π΄Ρ€ΡƒΠ³ΠΈΡ… Π½ΠΎΠ΄. ΠŸΠ΅Ρ€Π΅Ρ‚Π°Ρ‰ΠΈΡ‚Π΅, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠ΄Π²ΠΈΠ½ΡƒΡ‚ΡŒ; Alt ΠΈ пСрСтаскиваниС Π²Ρ‹Ρ‚ΡΠ³ΠΈΠ²Π°ΡŽΡ‚ ΠΈΠ· Π½Π΅Ρ‘ Π΅Ρ‰Ρ‘ ΠΎΠ΄Π½Ρƒ связь; Π΄Π²ΠΎΠΉΠ½ΠΎΠΉ ΠΊΠ»ΠΈΠΊ ΠΏΠΎ связи ставит Π½Π° Π½Π΅Ρ‘ Ρ‚ΠΎΡ‡ΠΊΡƒ. ΠŸΡ€ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ связь снова соСдиняСтся.",
   "group": "Π“Ρ€ΡƒΠΏΠΏΠ°",
-  "group_nodes": "Π‘Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
-  "ungroup": "Π Π°Π·Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
-  "leave_group": "Π£Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· Π³Ρ€ΡƒΠΏΠΏΡ‹",
-  "collapse_group": "Π‘Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ Π² ΠΎΠ΄Π½Ρƒ Π½ΠΎΠ΄Ρƒ",
-  "expand_group": "Π Π°Π·Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ",
-  "delete_group_nodes": "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ вмСстС с Π½ΠΎΠ΄Π°ΠΌΠΈ",
   "group_title": "НазваниС",
   "group_color": "Π¦Π²Π΅Ρ‚",
   "color.gray": "Π‘Π΅Ρ€Ρ‹ΠΉ",
@@ -69,6 +59,7 @@
   "no_problems": "Π“Ρ€Π°Ρ„ компилируСтся.",
   "problem.unknown_node": "НСизвСстный ΡƒΠ·Π΅Π»",
   "problem.unknown_pin": "НСизвСстный ΠΏΠΈΠ½",
+  "problem.missing_node": "Бвязь Π²Π΅Π΄Ρ‘Ρ‚ ΠΊ ΡƒΠ·Π»Ρƒ, ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ³ΠΎ Π½Π΅Ρ‚ Π² Π³Ρ€Π°Ρ„Π΅",
   "problem.no_output_node": "Π£ Π³Ρ€Π°Ρ„Π° Π½Π΅Ρ‚ Π²Ρ‹Ρ…ΠΎΠ΄Π½ΠΎΠ³ΠΎ ΡƒΠ·Π»Π°",
   "problem.second_output_node": "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Π²Ρ‹Ρ…ΠΎΠ΄Π½ΠΎΠΉ ΡƒΠ·Π΅Π»",
   "problem.cycle": "Π£Π·Π΅Π» стоит Π² Ρ†ΠΈΠΊΠ»Π΅ ΠΈ ΠΏΡ€ΠΎΠΏΡƒΡ‰Π΅Π½",
@@ -156,4 +147,4 @@
   "node.ray.plane": "Π›ΡƒΡ‡ ΠΈ ΠΏΠ»ΠΎΡΠΊΠΎΡΡ‚ΡŒ",
   "node.ray.sphere": "Π›ΡƒΡ‡ ΠΈ сфСра",
   "node.ray.screen_position": "Π’ΠΎΡ‡ΠΊΠ° Π½Π° экранС"
-}
+}
\ No newline at end of file
diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.csh b/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.csh
index bc56d1489..23d28d822 100644
--- a/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.csh
+++ b/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.csh
@@ -133,8 +133,9 @@ void main() {
     bool boundary = false;
     for (int localIndex = 0; localIndex < pathSegmentCount; localIndex++) {
         vec4 segment = segments[segmentStart + localIndex];
-        if (rowOverlaps(segment, tileMin.y, tileMax.y, margin)) rowSegmentCount++;
-        if (tileOverlaps(segment, tileMin, tileMax, margin)) boundary = true;
+        bool near = tileOverlaps(segment, tileMin, tileMax, margin);
+        if (stroke ? near : rowOverlaps(segment, tileMin.y, tileMax.y, margin)) rowSegmentCount++;
+        if (near) boundary = true;
         winding += crossingDelta(segment, center);
     }
     if (!boundary && (stroke || winding == 0)) {
@@ -148,9 +149,9 @@ void main() {
         uint outputIndex = outputSegmentStart;
         for (int localIndex = 0; localIndex < pathSegmentCount; localIndex++) {
             vec4 segment = segments[segmentStart + localIndex];
-            if (rowOverlaps(segment, tileMin.y, tileMax.y, margin)) {
-                segmentIndices[outputIndex++] = segmentStart + localIndex;
-            }
+            bool kept = stroke ? tileOverlaps(segment, tileMin, tileMax, margin) :
+                rowOverlaps(segment, tileMin.y, tileMax.y, margin);
+            if (kept) segmentIndices[outputIndex++] = segmentStart + localIndex;
         }
     }
 
diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.fsh
index c5f5afb51..ef1908a8b 100644
--- a/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.fsh
+++ b/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.fsh
@@ -5,8 +5,10 @@ uniform isamplerBuffer SegmentIndexBuffer;
 uniform isamplerBuffer TileBuffer;
 uniform samplerBuffer PaintBuffer;
 uniform samplerBuffer StopBuffer;
+uniform samplerBuffer ClipBuffer;
 
 in vec2 localPosition;
+in vec2 clipPosition;
 flat in int tileIndex;
 
 layout(location = 0) out vec4 fragColor;
@@ -146,6 +148,11 @@ void main() {
     vec2 pixelX = dFdx(localPosition);
     vec2 pixelY = dFdy(localPosition);
     if (style.x < 0) discard;
+    vec4 clip = texelFetch(ClipBuffer, tile.z);
+    if (clipPosition.x < clip.x || clipPosition.y < clip.y ||
+        clipPosition.x > clip.z || clipPosition.y > clip.w) {
+        discard;
+    }
     float coverage = pathCoverage(tile, style, pixelX, pixelY);
     if (coverage <= 0.0) discard;
     fragColor = samplePaint(tile.z);
diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.vsh
index f79e42337..85f384841 100644
--- a/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.vsh
+++ b/runtime/src/main/resources/assets/hollowengine/shaders/ui/path_tile.vsh
@@ -8,10 +8,12 @@ uniform mat4 ModelViewMat;
 uniform mat4 ProjMat;
 
 out vec2 localPosition;
+out vec2 clipPosition;
 flat out int tileIndex;
 
 void main() {
     gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
     localPosition = LocalPosition;
+    clipPosition = Position.xy;
     tileIndex = int(TileIndex);
 }
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/error.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/error.png
deleted file mode 100644
index df4d823e2..000000000
Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/error.png and /dev/null differ
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/error.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/error.svg
new file mode 100644
index 000000000..0634496b2
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/error.svg
@@ -0,0 +1,5 @@
+
+  
+  
+  
+
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg
new file mode 100644
index 000000000..fe1c31ba2
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/action.svg
@@ -0,0 +1 @@
+
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg
new file mode 100644
index 000000000..2058a6164
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/checkpoint.svg
@@ -0,0 +1 @@
+
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg
new file mode 100644
index 000000000..553a6e404
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/rewind.svg
@@ -0,0 +1 @@
+
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg
new file mode 100644
index 000000000..7fa5b6cc7
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/stage.svg
@@ -0,0 +1 @@
+
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg
new file mode 100644
index 000000000..0e93d3a50
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/gutter/temporary.svg
@@ -0,0 +1 @@
+
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg
deleted file mode 100644
index a3821f228..000000000
--- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/orbit_selection.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/warn.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/warn.png
deleted file mode 100644
index b2e058d92..000000000
Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/warn.png and /dev/null differ
diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/warn.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/warn.svg
new file mode 100644
index 000000000..51e56a676
--- /dev/null
+++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/warn.svg
@@ -0,0 +1,5 @@
+
+  
+  
+  
+
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue.ui.kts b/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue.ui.kts
deleted file mode 100644
index e962e385a..000000000
--- a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue.ui.kts
+++ /dev/null
@@ -1,123 +0,0 @@
-package assets.hollowengine.ui.examples
-
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueChoicesView
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueLineView
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys
-
-val STYLE = "hollowengine:ui/examples/dialogue_overlay.hss"
-
-surface("project:dialogue") {
-    title = "Dialogue"
-    anchor = "chat"
-    placement = HudPlacement.AFTER
-    interactive()
-    closeOnEscape = false
-    guiScale = UiGuiScale.Auto
-    exitDuration = 260L
-
-    mode { data ->
-        if (data[DialogueUiKeys.Choices].options.isEmpty()) UiSurfaceKind.OVERLAY else UiSurfaceKind.SCREEN
-    }
-
-    content {
-        val line = data[DialogueUiKeys.Line]
-        val choices = data[DialogueUiKeys.Choices]
-
-        Box(id = "dialogue-root", mode = UiBoxMode.FREE, modifier = Modifier.style(STYLE)) {
-            if (choices.options.isEmpty()) {
-                LineOverlay(line)
-            } else {
-                ChoiceWindow(choices)
-            }
-        }
-    }
-}
-
-@Composable
-fun UiScope.LineOverlay(line: DialogueLineView) {
-    val waiting = line.awaiting
-    var catcher: Modifier = Modifier
-    if (waiting) {
-        catcher = catcher.input(clickable = true).onClick { event ->
-            if (event.isRightClick()) send { putString(DialogueUiKeys.Action, DialogueUiKeys.AdvanceAction) }
-        }
-    }
-    Box(id = "advance-catcher", modifier = catcher)
-
-    Box(id = "line-panel", mode = UiBoxMode.FREE) {
-        Row(id = "line-box") {
-            if (line.speakerEntity >= 0) {
-                Box(id = "portrait-frame") { Entity(line.speakerEntity, id = "portrait") }
-            }
-            TypedText(line)
-        }
-
-        Box(
-            id = "advance-hint",
-            modifier = if (waiting) Modifier.state(UiState.of("ready")) else null,
-        ) {
-            Image("hollowengine:textures/gui/icons/mouse_right.png", id = "advance-hint-icon")
-        }
-    }
-}
-
-@Composable
-fun TypedText(line: DialogueLineView) {
-    val reveal = remember(line.id) { mutableStateOf(if (line.awaiting) line.visibleLength else 0) }
-
-    LaunchedEffect(line.id, line.visibleLength, line.charDelay) {
-        if (line.charDelay <= 0) {
-            reveal.value = line.visibleLength
-            return@LaunchedEffect
-        }
-        val from = reveal.value
-        var startNanos = -1L
-        while (reveal.value < line.visibleLength) {
-            withFrameNanos { now ->
-                if (startNanos < 0L) startNanos = now
-                val elapsed = (now - startNanos) / 1_000_000L
-                reveal.value = (from + elapsed / line.charDelay).toInt().coerceAtMost(line.visibleLength)
-            }
-        }
-    }
-
-    LaunchedEffect(line.id, line.skips) {
-        if (line.skips > 0) reveal.value = line.visibleLength
-    }
-
-    val shown = reveal.value.coerceIn(0, line.visibleLength)
-    Text(tags = listOf("line-text")) {
-        if (line.speaker.isNotEmpty()) Span("[${line.speaker}]: ", tags = listOf("speaker-text"))
-        FormattedTextSpans(
-            value = line.text,
-            visibleCharacters = shown,
-            pendingTags = listOf("line-text-pending"),
-        )
-    }
-}
-
-@Composable
-fun UiScope.ChoiceWindow(choices: DialogueChoicesView) {
-    Column(id = "choice-window") {
-        choices.options.forEach { option ->
-            val voted = choices.myVote >= 0
-            var button: Modifier = Modifier
-            if (voted && choices.myVote != option.index) button = button.state(UiState.DISABLED)
-            if (choices.myVote == option.index || choices.decided == option.index) {
-                button = button.state(UiState.SELECTED)
-            }
-            if (!voted && choices.decided < 0) {
-                button = button.input(hoverable = true, clickable = true).onClick {
-                    send {
-                        putString(DialogueUiKeys.Action, DialogueUiKeys.ChooseAction)
-                        putInt(DialogueUiKeys.Index, option.index)
-                    }
-                }
-            }
-
-            Box(tags = listOf("choice"), modifier = button) {
-                FormattedText(option.text, tags = listOf("choice-text"))
-            }
-        }
-    }
-}
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue_overlay.hss b/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue_overlay.hss
deleted file mode 100644
index 8530e924c..000000000
--- a/runtime/src/main/resources/assets/hollowengine/ui/examples/dialogue_overlay.hss
+++ /dev/null
@@ -1,173 +0,0 @@
-#dialogue-root {
-    size: fill fill;
-    align-items: center center;
-}
-
-#advance-catcher {
-    size: fill fill;
-    align: center center;
-}
-
-#line-panel {
-    size: fit fit;
-    width: 32%;
-    max-width: 306px;
-    margin: 0px 0px 28px 0px;
-    align: center end;
-    align-items: start start;
-
-    animations: line-in 220ms ease-out both;
-}
-
-@keyframes line-in {
-    from { opacity: 0; translate: 0 8 0; }
-    to   { opacity: 1; translate: 0 0 0; }
-}
-
-#line-box {
-    size: fill fit;
-    min-height: 36px;
-    align-items: start start;
-    gap: 7px;
-    padding: 7px;
-
-    background: rgba(8, 9, 12, 0.62);
-    backdrop-filter: blur(12px);
-    border: 1px #ffffff;
-    border-radius: 2px;
-}
-
-#speaker {
-    size: fit fit;
-    gap: 3px;
-    align-items: center start;
-}
-
-.speaker-name {
-    font-size: 7px;
-    color: #ffffff;
-    text-wrap: false;
-}
-
-.speaker-text {
-    color: #ffffff;
-    text-effects: pulse, outline(0.5px, #379cde88);
-}
-
-#portrait-frame {
-    size: 30px 30px;
-    clip: true;
-    align-items: center start;
-    background: rgba(255, 255, 255, 0.06);
-    border: 1px rgba(255, 255, 255, 0.45);
-    border-radius: 2px;
-}
-
-#portrait {
-    size: 30px 60px;
-    align: center start;
-    margin-top: 7px;
-    rotate-y: -20;
-}
-
-.line-text {
-    size: fill fit;
-    grow: 1;
-    font-size: 7px;
-    color: #f2f2f2;
-    line-spacing: 2px;
-    text-wrap: true;
-}
-
-.line-text-pending {
-    opacity: 0;
-}
-
-#advance-hint {
-    size: 16px 16px;
-    align: end end;
-    margin: 0px -8px -8px 0px;
-    align-items: center center;
-
-    background: rgba(8, 9, 12, 0.82);
-    border: 1px #ffffff;
-    border-radius: 2px;
-    input-transparent: true;
-
-    opacity: 0;
-    transitions: opacity 160ms ease-out;
-}
-
-#advance-hint-icon {
-    size: 8px 11px;
-    image-fit: contain;
-    input-transparent: true;
-}
-
-#advance-hint:ready {
-    opacity: 1;
-    animations: hint-float 1600ms ease-in-out infinite;
-}
-
-@keyframes hint-float {
-    0%   { translate: 0 0 0; }
-    50%  { translate: 0 -2 0; }
-    100% { translate: 0 0 0; }
-}
-
-#choice-window {
-    size: fit fit;
-    min-width: 146px;
-    align: center center;
-    align-items: stretch start;
-    gap: 4px;
-    padding: 8px;
-
-    background: rgba(8, 9, 12, 0.68);
-    backdrop-filter: blur(14px);
-    border: 1px #ffffff;
-    border-radius: 2px;
-
-    animations: line-in 200ms ease-out both;
-}
-
-.choice {
-    size: fill fit;
-    padding: 4px 7px 4px 7px;
-    align-items: center start;
-
-    background: rgba(255, 255, 255, 0.05);
-    border: 1px rgba(255, 255, 255, 0.35);
-    border-radius: 2px;
-    cursor: pointer;
-
-    transitions:
-        background 110ms ease-out,
-        translate 110ms ease-out,
-        opacity 160ms ease-out,
-        filter 160ms ease-out;
-}
-
-.choice-text {
-    font-size: 7px;
-    color: #e8e8e8;
-    text-wrap: false;
-}
-
-.choice:hover {
-    background: rgba(255, 255, 255, 0.16);
-    translate: 3 0 0;
-}
-
-.choice:hover .choice-text {
-    color: #ffffff;
-}
-
-.choice:selected {
-    background: rgba(255, 255, 255, 0.22);
-}
-
-.choice:disabled {
-    filter: grayscale(1);
-    opacity: 0.5;
-}
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss
index 2814a73ad..dcf66dd29 100644
--- a/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss
+++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/animator-editor.hss
@@ -7,6 +7,14 @@
     border-radius: $radius;
 }
 
+.viewport-transform-hint {
+    padding: 4px 6px;
+    background: alpha($bg-window, 0.86);
+    foreground: $text-strong;
+    font-size: $font-size;
+    border-radius: $radius;
+}
+
 /* A state of a controller graph; the frame is the graph node's. */
 .animator-state {
     padding: 6px 8px;
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss
deleted file mode 100644
index c7b4a2c20..000000000
--- a/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss
+++ /dev/null
@@ -1,238 +0,0 @@
-/* Example dialogue window */
-
-#dialogue-root {
-    size: fill fill;
-    align-items: center center;
-}
-
-#dialogue-dim {
-    size: fill fill;
-    align: center center;
-    background: rgba(0, 0, 0, 0.30);
-    backdrop-filter: blur(3px);
-    input-transparent: true;
-    animations: dim-in 260ms ease-out both;
-}
-
-#dialogue-root:closing #dialogue-dim {
-    animations: dim-out 220ms ease-in both;
-}
-
-@keyframes dim-in {
-    from { opacity: 0; }
-    to   { opacity: 1; }
-}
-
-@keyframes dim-out {
-    from { opacity: 1; }
-    to   { opacity: 0; }
-}
-
-#dialogue-box {
-    size: fit fit;
-    width: 62%;
-    max-width: 440px;
-    min-height: 52px;
-    margin: 0px 0px 8px 0px;
-    align: center end;
-    align-items: start start;
-
-    background: image("hollowengine:textures/gui/dialogues/dialogue_box.png");
-    image-fit: 9-slice 30px 8px 30px 8px;
-    padding: 14px 12px 8px 12px;
-
-    perspective: 700px;
-    transform-origin: 50% 100%;
-    animations: box-in 700ms cubic-bezier(0.34, 1.76, 0.64, 1) both;
-}
-
-#dialogue-root:closing #dialogue-box {
-    animations: box-out 720ms ease-in both;
-}
-
-@keyframes box-in {
-    from { opacity: 0; translate: 0 22 0; rotate: 26 0 0; }
-    to   { opacity: 1; translate: 0 0 0;  rotate: 0 0 0; }
-}
-
-@keyframes box-out {
-    from { opacity: 1; translate: 0 0 0;  rotate: 0 0 0; }
-    to   { opacity: 0; translate: 0 18 0; rotate: 12 0 0; }
-}
-
-#dialogue-name {
-    size: fit fit;
-    min-height: 18px;
-    height: 16px;
-    margin: -29px 0px 7px -6px;
-    padding: 0px 10px 0px 10px;
-    align-items: center center;
-
-    background: image("hollowengine:textures/gui/dialogues/character_name.png");
-    image-fit: 9-slice 7px;
-
-    perspective: 400px;
-    transform-origin: 0% 100%;
-    animations: name-in 700ms ease-out both;
-}
-
-#dialogue-root:closing #dialogue-name {
-    animations: box-out 700ms ease-in both;
-}
-
-@keyframes name-in {
-    from { opacity: 0; translate: -10 6 0; rotate: 0 -22 0; }
-    to   { opacity: 1; translate: 0 0 0;   rotate: 0 0 0; }
-}
-
-.dialogue-name-text {
-    font-size: 7px;
-    color: #ffdca8;
-}
-
-#dialogue-body {
-    size: fill fit;
-    align-items: start start;
-}
-
-.dialogue-text {
-    size: fill fit;
-    font-size: 9px;
-    color: #f4ece0;
-    line-spacing: 2px;
-    text-wrap: true;
-}
-
-.dialogue-text-pending {
-    opacity: 0;
-}
-
-#dialogue-continue {
-    size: 11px 12px;
-    align: end end;
-    scale: -1 1;
-    image-fit: contain;
-    input-transparent: true;
-    opacity: 0;
-}
-
-#dialogue-continue:ready {
-    animations: continue-nudge 900ms ease-in-out infinite;
-}
-
-@keyframes continue-nudge {
-    0%   { opacity: 0.35; translate: 0 0 0; }
-    50%  { opacity: 1.00; translate: -3 0 0; }
-    100% { opacity: 0.35; translate: 0 0 0; }
-}
-
-#choice-viewport {
-    size: fit fit;
-    width: 300px;
-    height: 132px;
-    align: center center;
-    margin: 0px 0px 40px 0px;
-
-    clip: true;
-
-    mask: linear-gradient(to bottom, transparent, white 26%, white 74%, transparent);
-}
-
-#choice-list {
-    size: fill fit;
-    align-items: center start;
-    gap: 4px;
-}
-
-.choice {
-    size: fit fit;
-    gap: 4px;
-    align-items: center center;
-    margin-left: 22px;
-    transform-origin: 50% 50%;
-    animations: choice-in 340ms ease-out both;
-    transitions:
-        scale 110ms ease-out,
-        opacity 160ms ease-out,
-        filter 160ms ease-out;
-}
-
-@keyframes choice-in {
-    from { opacity: 0; translate: 14 0 0; }
-    to   { opacity: 1; translate: 0 0 0; }
-}
-
-.choice-0 { animation-delay: 0ms; }
-.choice-1 { animation-delay: 70ms; }
-.choice-2 { animation-delay: 140ms; }
-.choice-3 { animation-delay: 210ms; }
-.choice-4 { animation-delay: 280ms; }
-.choice-5 { animation-delay: 350ms; }
-.choice-6 { animation-delay: 420ms; }
-.choice-7 { animation-delay: 490ms; }
-
-.choice-button {
-    size: fit fit;
-    min-width: 172px;
-    height: 24px;
-    padding: 0px 14px 0px 14px;
-    align-items: center center;
-
-    background: image("hollowengine:textures/gui/dialogues/button.png");
-    image-fit: 9-slice 12px;
-    cursor: pointer;
-}
-
-.choice-text {
-    font-size: 9px;
-    color: #f6e3c4;
-    text-wrap: false;
-}
-
-.choice-desc {
-    size: 32px 24px;
-    image-fit: contain;
-    transitions: translate 110ms ease-out;
-}
-
-.choice-cursor {
-    size: 22px 24px;
-    image-fit: contain;
-    opacity: 0;
-    translate: 8 0 0;
-    transitions:
-        opacity 110ms ease-out,
-        translate 110ms ease-out;
-}
-
-.choice:hover { scale: 1.06 1.06; }
-.choice:hover .choice-desc { translate: 6 0 0; }
-.choice:hover .choice-cursor { opacity: 1; translate: -6 0 0; }
-.choice:hover .choice-text { color: #ffffff; }
-
-.choice:disabled {
-    filter: grayscale(1);
-    opacity: 0.55;
-}
-
-.choice:selected .choice-text { color: #ffffff; }
-
-.choice:lost {
-    animations: choice-lost 260ms ease-in both;
-}
-
-.choice:won {
-    animations: choice-won 320ms ease-in both;
-    animation-delay: 180ms;
-}
-
-@keyframes choice-lost {
-    from { opacity: 1; scale: 1 1; }
-    to   { opacity: 0; scale: 0.86 0.86; }
-}
-
-@keyframes choice-won {
-    0%   { opacity: 1; scale: 1.06 1.06; }
-    35%  { opacity: 1; scale: 1.12 1.12; }
-    100% { opacity: 0; scale: 0.9 0.9; }
-}
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/shader-graph.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/graph-nodes.hss
similarity index 52%
rename from runtime/src/main/resources/assets/hollowengine/ui/styles/shader-graph.hss
rename to runtime/src/main/resources/assets/hollowengine/ui/styles/graph-nodes.hss
index 2a1ff7bf8..b4e588ce5 100644
--- a/runtime/src/main/resources/assets/hollowengine/ui/styles/shader-graph.hss
+++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/graph-nodes.hss
@@ -1,9 +1,5 @@
 @import "hollowengine:ui/styles/theme.hss";
 
-/* The shader graph editor: nodes with a title bar in the color of their category, pins in the color
-   of what they carry, filled once something is linked to them, and fields to edit values in place.
-   A node has no frame; it is told apart by its title bar and, when picked, by a ring around it. */
-
 $node-bg: #252525;
 $node-field: #212121;
 $node-line: alpha(#FFFFFF, 0.1);
@@ -11,8 +7,6 @@ $node-divider: alpha(#000000, 0.2);
 $node-radius: 8px;
 $node-field-radius: 5px;
 
-/* The outline of a title bar: lighter than its color along the top and the sides, darker along the bottom.
-   CSS gradient angles run from the top clockwise, so to bottom is 180deg. */
 $head-light: alpha(#FFFFFF, 0.14);
 $head-dark: alpha(#000000, 0.28);
 
@@ -25,26 +19,47 @@ $category-texture: #C067A4;
 $category-procedural: #5F59B1;
 $category-output: #CF5E54;
 
-.graph-node.sg-node {
+.graph-node.selected {
+    layer: 1;
+}
+
+.graph-node.gn-node {
     background: $node-bg;
     border: 0px $node-line;
     border-radius: $node-radius;
     shadow: 0px 3px 10px 0px $shadow;
 }
 
-.graph-node.sg-node.selected {
+.graph-node.gn-node.selected {
     shadow: 0px 3px 10px 0px $shadow, 0px 0px 0px 1.5px $accent;
 }
 
-.graph-node.sg-node.error {
+.graph-node.gn-node.error {
     shadow: 0px 3px 10px 0px $shadow, 0px 0px 0px 1.5px $danger;
 }
 
-.graph-node.sg-node.collapsed {
+.graph-node.gn-node.warning {
+    shadow: 0px 3px 10px 0px $shadow, 0px 0px 0px 1.5px $warning;
+}
+
+.graph-node.gn-node.gn-found {
+    shadow: 0px 3px 10px 0px $shadow, 0px 0px 0px 1.5px alpha($accent, 0.55);
+}
+
+.graph-node.gn-node.gn-found.selected {
+    shadow: 0px 3px 10px 0px $shadow, 0px 0px 0px 2.5px $accent;
+}
+
+.gn-node-problem {
+    size: 13px 13px;
+    margin: 0px 5px 0px 0px;
+}
+
+.graph-node.gn-node.collapsed {
     opacity: 0.65;
 }
 
-.sg-node-head {
+.gn-node-head {
     padding: 0px 10px;
     gap: 5px;
     background: $category-math;
@@ -52,40 +67,38 @@ $category-output: #CF5E54;
     border-radius: $node-radius;
 }
 
-/* Squares off the bottom corners of an open node's title bar, and outlines its sides again. */
-.sg-node-head-flat {
+.gn-node-head-flat {
     background: $category-math;
 }
 
-.sg-node-head-edge {
+.gn-node-head-edge {
     background: $head-light;
 }
 
-.sg-input .sg-node-head, .sg-input .sg-node-head-flat { background: $category-input; }
-.sg-vector .sg-node-head, .sg-vector .sg-node-head-flat { background: $category-vector; }
-.sg-uv .sg-node-head, .sg-uv .sg-node-head-flat { background: $category-uv; }
-.sg-normal .sg-node-head, .sg-normal .sg-node-head-flat { background: $category-normal; }
-.sg-texture .sg-node-head, .sg-texture .sg-node-head-flat { background: $category-texture; }
-.sg-procedural .sg-node-head, .sg-procedural .sg-node-head-flat { background: $category-procedural; }
-.sg-output .sg-node-head, .sg-output .sg-node-head-flat { background: $category-output; }
+.gn-input .gn-node-head, .gn-input .gn-node-head-flat { background: $category-input; }
+.gn-vector .gn-node-head, .gn-vector .gn-node-head-flat { background: $category-vector; }
+.gn-uv .gn-node-head, .gn-uv .gn-node-head-flat { background: $category-uv; }
+.gn-normal .gn-node-head, .gn-normal .gn-node-head-flat { background: $category-normal; }
+.gn-texture .gn-node-head, .gn-texture .gn-node-head-flat { background: $category-texture; }
+.gn-procedural .gn-node-head, .gn-procedural .gn-node-head-flat { background: $category-procedural; }
+.gn-output .gn-node-head, .gn-output .gn-node-head-flat { background: $category-output; }
 
-.sg-node-divider {
+.gn-node-divider {
     background: $node-divider;
 }
 
-.sg-node-icon {
+.gn-node-icon {
     size: 15px 15px;
 }
 
-.sg-node-head .sg-node-title {
+.gn-node-head .gn-node-title {
     font-size: 12px;
     foreground: $white;
     text-wrap: false;
     text-overflow: dots;
 }
 
-/* Collapses the node; the arrow points up while it is open. */
-.sg-node-toggle {
+.gn-node-toggle {
     size: 16px 16px;
     align-items: center center;
     border-radius: $radius;
@@ -93,42 +106,41 @@ $category-output: #CF5E54;
     transition: background 110ms ease-out;
 }
 
-.sg-node-toggle:hover {
+.gn-node-toggle:hover {
     background: alpha(#FFFFFF, 0.16);
 }
 
-.sg-node-arrow {
+.gn-node-arrow {
     size: 7px 5px;
     transform: rotate(180deg);
 }
 
-.collapsed .sg-node-arrow {
+.collapsed .gn-node-arrow {
     transform: rotate(0deg);
 }
 
-.sg-node-row {
+.gn-node-row {
     gap: 6px;
 }
 
-.sg-pin-label {
+.gn-pin-label {
     font-size: 11px;
     foreground: $white;
     text-wrap: false;
     text-overflow: dots;
 }
 
-/* What an unlinked input reads instead of a value, such as the UV. */
-.sg-pin-hint {
+.gn-pin-hint {
     font-size: 9px;
     foreground: $text-faint;
     text-wrap: false;
 }
 
-.sg-fields {
+.gn-fields {
     gap: 4px;
 }
 
-.sg-field {
+.gn-field {
     padding: 3px 6px;
     background: $node-field;
     border: 1px $node-line;
@@ -142,22 +154,20 @@ $category-output: #CF5E54;
     transition: border 110ms ease-out;
 }
 
-.sg-field:hover {
+.gn-field:hover {
     border-color: $border;
 }
 
-.sg-field:focus {
+.gn-field:focus {
     border-color: $accent;
 }
 
-/* An expression is code. */
-.sg-expression {
+.gn-expression {
     font-family: $font-code;
     font-size: 9px;
 }
 
-/* What a choice is set to; a press opens its values. */
-.sg-chip {
+.gn-chip {
     padding: 3px 6px;
     background: $node-field;
     border: 1px $node-line;
@@ -171,29 +181,28 @@ $category-output: #CF5E54;
     transition: border 110ms ease-out, background 110ms ease-out;
 }
 
-.sg-chip:hover {
+.gn-chip:hover {
     background: $bg-hover;
     border-color: $border;
 }
 
-.sg-toggle {
+.gn-toggle {
     gap: 6px;
     cursor: hand;
 }
 
-.sg-checkbox {
+.gn-checkbox {
     size: 10px 10px;
     checkbox-active: $accent;
     checkbox-mark: $white;
 }
 
-.sg-node-preview {
+.gn-node-preview {
     background: $bg-window;
     border-radius: $node-field-radius;
 }
 
-/* A dot on the edge of the node: a ring while nothing is linked, filled once something is. */
-.sg-pin {
+.gn-pin {
     background: $node-bg;
     border: 1.5px #A5ABC0;
     border-radius: 4.5px;
@@ -202,46 +211,44 @@ $category-output: #CF5E54;
     transition: background 150ms ease-out;
 }
 
-.sg-pin:hover {
+.gn-pin:hover {
     background: $white;
 }
 
-.sg-pin.sg-type-float { border-color: #A5ABC0; }
-.sg-pin.sg-type-vec2 { border-color: #44BC74; }
-.sg-pin.sg-type-vec3 { border-color: #F0CF60; }
-.sg-pin.sg-type-vec4 { border-color: #C774AA; }
-.sg-pin.sg-type-texture { border-color: #C9874A; }
+.gn-pin.gn-type-float { border-color: #A5ABC0; }
+.gn-pin.gn-type-vec2 { border-color: #44BC74; }
+.gn-pin.gn-type-vec3 { border-color: #F0CF60; }
+.gn-pin.gn-type-vec4 { border-color: #C774AA; }
+.gn-pin.gn-type-texture { border-color: #C9874A; }
 
-.sg-pin.connected.sg-type-float { background: #A5ABC0; }
-.sg-pin.connected.sg-type-vec2 { background: #44BC74; }
-.sg-pin.connected.sg-type-vec3 { background: #F0CF60; }
-.sg-pin.connected.sg-type-vec4 { background: #C774AA; }
-.sg-pin.connected.sg-type-texture { background: #C9874A; }
+.gn-pin.connected.gn-type-float { background: #A5ABC0; }
+.gn-pin.connected.gn-type-vec2 { background: #44BC74; }
+.gn-pin.connected.gn-type-vec3 { background: #F0CF60; }
+.gn-pin.connected.gn-type-vec4 { background: #C774AA; }
+.gn-pin.connected.gn-type-texture { background: #C9874A; }
 
-/* A texture is a reference to an object rather than a value, which the design draws as a square. */
-.sg-pin.sg-type-texture {
+.gn-pin.gn-type-texture {
     border-radius: 2px;
 }
 
-/* A number of a node keeps the node's field look; its parts bring their own room. */
-.number-field.sg-field { padding: 0px; }
+.number-field.gn-field { padding: 0px; }
 
-.number-field.sg-field .number-field-frame { size: 100% 100%; }
+.number-field.gn-field .number-field-frame { size: 100% 100%; }
 
-.number-field.sg-field.editing { padding: 1px 4px; }
+.number-field.gn-field.editing { padding: 1px 4px; }
 
-.sg-swatch {
+.gn-swatch {
     border: 1px alpha(#FFFFFF, 0.28);
     border-radius: $node-field-radius;
     cursor: hand;
     transition: border 110ms ease-out;
 }
 
-.sg-swatch:hover {
+.gn-swatch:hover {
     border-color: $border-strong;
 }
 
-.sg-color-popup {
+.gn-color-popup {
     padding: 8px;
     background: $bg-raised;
     border: 1px $border;
@@ -251,32 +258,30 @@ $category-output: #CF5E54;
     font-size: $font-size;
 }
 
-.sg-color-popup-body {
+.gn-color-popup-body {
     gap: 6px;
 }
 
-/* A reroute: a dot in the color of what passes through it, ringed when it is picked. */
-.graph-node.sg-reroute {
+.graph-node.gn-reroute {
     background: #A5ABC0;
     border: 1.5px alpha(#000000, 0.35);
     border-radius: 6px;
     shadow: 0px 1px 3px 0px alpha(#000000, 0.55);
 }
 
-.graph-node.sg-reroute:hover {
+.graph-node.gn-reroute:hover {
     border-color: $white;
 }
 
-.graph-node.sg-reroute.selected {
+.graph-node.gn-reroute.selected {
     shadow: 0px 1px 3px 0px alpha(#000000, 0.55), 0px 0px 0px 1.5px $accent;
 }
 
-.graph-node.sg-reroute.sg-type-vec2 { background: #44BC74; }
-.graph-node.sg-reroute.sg-type-vec3 { background: #F0CF60; }
-.graph-node.sg-reroute.sg-type-vec4 { background: #C774AA; }
-.graph-node.sg-reroute.sg-type-texture { background: #C9874A; border-radius: 2px; }
+.graph-node.gn-reroute.gn-type-vec2 { background: #44BC74; }
+.graph-node.gn-reroute.gn-type-vec3 { background: #F0CF60; }
+.graph-node.gn-reroute.gn-type-vec4 { background: #C774AA; }
+.graph-node.gn-reroute.gn-type-texture { background: #C9874A; border-radius: 2px; }
 
-/* A collapsed group: a node whose title bar takes the group's color, and whose rows only name pins. */
-.graph-node.sg-group-card .sg-node-title {
+.graph-node.gn-group-card .gn-node-title {
     foreground: $white;
 }
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss
index 72db70a90..81be02853 100644
--- a/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss
+++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss
@@ -57,6 +57,54 @@
     border-color: $text-strong;
 }
 
+.graph-search {
+    gap: 4px;
+    padding: 2px 4px 2px 8px;
+    background: $bg-raised;
+    border: 1px $border;
+    border-radius: $radius-large;
+    shadow: 0px 4px 12px 0px $shadow;
+}
+
+.graph-search-icon {
+    size: 12px 12px;
+    tint: $text-soft;
+}
+
+.graph-search-input {
+    height: 22px;
+    background: transparent;
+    caret-color: $caret;
+    selection-color: $selection;
+}
+
+.graph-search-count {
+    min-width: 36px;
+    font-size: $font-size-small;
+    foreground: $text-muted;
+    text-wrap: false;
+}
+
+.graph-search-count.missing {
+    foreground: $danger;
+}
+
+.graph-search-button {
+    size: 20px 20px;
+    align-items: center center;
+    border-radius: $radius;
+    transition: background 110ms ease-out;
+}
+
+.graph-search-button:hover {
+    background: $bg-hover;
+}
+
+.graph-search-button-icon {
+    size: 10px 10px;
+    tint: $text-soft;
+}
+
 .graph-marquee {
     background: alpha($accent, 0.08);
     border: 1px alpha($accent, 0.7);
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss
index 05d3f142e..4cfebf953 100644
--- a/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss
+++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss
@@ -129,6 +129,10 @@
 
 .timeline-row.muted .timeline-row-label { foreground: $text-faint; }
 
+.timeline-row.scene-selected { background: alpha($accent, 0.18); }
+.timeline-row.scene-selected:hover { background: alpha($accent, 0.28); }
+.timeline-row.scene-selected .timeline-row-label { foreground: $accent; }
+
 /* The strips at the ends of a block that trim it: shown only while the pointer is on one. */
 .timeline-segment-grip {
     background: transparent;
diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/widgets.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/widgets.hss
index c1c3e9242..25d4838b1 100644
--- a/runtime/src/main/resources/assets/hollowengine/ui/styles/widgets.hss
+++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/widgets.hss
@@ -191,6 +191,34 @@
     opacity: 0.42;
 }
 
+.dropdown-search {
+    width: 100%;
+    gap: 5px;
+    margin: 0px 0px 3px 0px;
+    padding: 0px 6px;
+    background: $bg-field;
+    border: 1px $border;
+    border-radius: $radius;
+}
+
+.dropdown-search-icon {
+    size: 12px 12px;
+    tint: $text-soft;
+}
+
+.dropdown-search-input {
+    height: 22px;
+    min-width: 180px;
+    background: transparent;
+    caret-color: $caret;
+    selection-color: $selection;
+}
+
+/* What Enter would pick of what the search found. */
+.dropdown-item.picked {
+    background: alpha($accent, 0.28);
+}
+
 .tree-view {
     clip: true;
 }
@@ -451,6 +479,10 @@
     clip: true;
 }
 
+.ide-completion-preview {
+    padding: 4px;
+}
+
 .ide-completion-divider {
     background: $bg-hover;
 }
diff --git a/runtime/src/main/resources/hollowengine.accesswidener b/runtime/src/main/resources/hollowengine.accesswidener
index 6bad9009e..3df3f0967 100644
--- a/runtime/src/main/resources/hollowengine.accesswidener
+++ b/runtime/src/main/resources/hollowengine.accesswidener
@@ -62,6 +62,8 @@ accessible field net/minecraft/world/item/ItemStackLinkedSet TYPE_AND_TAG Lit/un
 
 accessible field net/minecraft/client/gui/screens/inventory/AbstractContainerScreen imageWidth I
 accessible field net/minecraft/client/gui/screens/inventory/AbstractContainerScreen imageHeight I
+accessible field net/minecraft/client/gui/screens/inventory/AbstractContainerScreen leftPos I
+accessible field net/minecraft/client/gui/screens/inventory/AbstractContainerScreen topPos I
 
 accessible field net/minecraft/server/network/ServerLoginPacketListenerImpl connection Lnet/minecraft/network/Connection;
 
diff --git a/runtime/src/main/resources/internal/templates/dialogue.story.template b/runtime/src/main/resources/internal/templates/dialogue.story.template
deleted file mode 100644
index 58c6ab818..000000000
--- a/runtime/src/main/resources/internal/templates/dialogue.story.template
+++ /dev/null
@@ -1,11 +0,0 @@
-// A dialogue. Start it from a Kotlin script:
-//     DialogueController("{{path}}").start(player)
-
-# Start
-
-Guide: Hello! Edit this file to write your own scene.
-
-@choice "Tell me more" id=more
-    Guide: Each @choice is an answer, and the indented lines below it play when it is picked.
-@choice "Goodbye" id=bye
-    Guide: See you later!
diff --git a/runtime/src/test/kotlin/MontageRigEditingTest.kt b/runtime/src/test/kotlin/MontageRigEditingTest.kt
index cfe65c90c..5ace5ddcb 100644
--- a/runtime/src/test/kotlin/MontageRigEditingTest.kt
+++ b/runtime/src/test/kotlin/MontageRigEditingTest.kt
@@ -9,8 +9,23 @@ import ru.hollowhorizon.hollowengine.common.utils.math.eulerDegreesXyz
 import ru.hollowhorizon.hollowengine.common.utils.math.eulerRotationXyz
 import kotlin.test.Test
 import kotlin.test.assertTrue
+import kotlin.test.assertEquals
 
 class MontageRigEditingTest {
+    @Test
+    fun `picking a bone updates track highlights without filtering the track list`() {
+        val (_, session) = session(recording = false)
+        session.select("Body", emptyList())
+        val body = session.timeline.groups.single { it.isSelected }
+        session.select("Arm", emptyList())
+        val arm = session.timeline.groups.single { it.isSelected }
+        assertTrue(body !== arm)
+        assertTrue(body.isListed)
+        assertTrue(!body.isSelected)
+        session.select(null, emptyList())
+        assertEquals(0, session.timeline.groups.count { it.isSelected })
+    }
+
     private fun session(recording: Boolean): Pair {
         val document = HollowIdeMontageDocument("{\"duration\": 2.0}".toByteArray())
         val rigs = MontageRigEditing(document) { ModelRig() }
diff --git a/runtime/src/test/kotlin/ScriptSourceTests.kt b/runtime/src/test/kotlin/ScriptSourceTests.kt
index 674f37ec0..a4ab31d6d 100644
--- a/runtime/src/test/kotlin/ScriptSourceTests.kt
+++ b/runtime/src/test/kotlin/ScriptSourceTests.kt
@@ -8,6 +8,22 @@ import java.util.jar.JarOutputStream
 import kotlin.test.*
 
 class ScriptSourceTests {
+    @Test
+    fun `compiled metadata resolves file paths URIs and portable identities`(@TempDir root: File) {
+        val file = root.resolve("a script.kts").apply { writeText("class Example") }
+        val id = ScriptId("metadata-locations", file.name)
+        ScriptRegistry.register(directorySource(id.namespace, root))
+        try {
+            ScriptRegistry.artifacts(id)
+            assertEquals(id, ScriptRegistry.idOfLocation(file.path))
+            assertEquals(id, ScriptRegistry.idOfLocation(file.toURI().toString()))
+            assertEquals(id, ScriptRegistry.idOfLocation(id.qualified))
+            assertNull(ScriptRegistry.idOfLocation("missing-namespace:missing.kts"))
+        } finally {
+            ScriptRegistry.unregister(id.namespace)
+        }
+    }
+
     @Test
     fun `unqualified paths keep their pre-namespace spelling`() {
         val id = ScriptRegistry.parse("scripts/nodes/example.node.kts")
diff --git a/runtime/src/test/kotlin/SharedScriptClassesTest.kt b/runtime/src/test/kotlin/SharedScriptClassesTest.kt
new file mode 100644
index 000000000..fec14726a
--- /dev/null
+++ b/runtime/src/test/kotlin/SharedScriptClassesTest.kt
@@ -0,0 +1,79 @@
+import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptCache.SharedScriptRef
+import ru.hollowhorizon.hollowengine.common.scripting.compiling.SharedScriptClasses
+import ru.hollowhorizon.hollowengine.common.scripting.compiling.isSharedScript
+import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptFingerprint
+import ru.hollowhorizon.hollowengine.common.scripting.compiling.sharedScriptId
+import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptId
+import kotlin.script.experimental.api.ScriptCompilationConfiguration
+import kotlin.script.experimental.jvm.impl.KJvmCompiledScript
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotSame
+import kotlin.test.assertTrue
+
+class SharedScriptClassesTest {
+    @Test
+    fun `an unresolved shared identity fails instead of loading a private copy`() {
+        val script = sharedScript("UnregisteredShared")
+        val failure = assertFailsWith {
+            SharedScriptClasses.loadersFor(listOf(script), emptyMap(), javaClass.classLoader)
+        }
+        assertTrue(failure.message.orEmpty().contains("Cannot resolve shared script identity"))
+        assertTrue(failure.message.orEmpty().contains("UnregisteredShared"))
+    }
+
+    @Test
+    fun `equal generated class names in separate import graphs keep independent namespaces`() {
+        val script = sharedScript("IndependentShared")
+        fun load(namespace: String): ClassLoader {
+            val reference = SharedScriptRef("IndependentShared", ScriptId(namespace, "common.kts"), "revision")
+            return SharedScriptClasses.loadersFor(
+                listOf(script), mapOf("IndependentShared.class" to byteArrayOf()), javaClass.classLoader,
+                mapOf("IndependentShared" to reference),
+            ).getValue("IndependentShared")
+        }
+        assertNotSame(load("independent-one"), load("independent-two"))
+    }
+
+    @Test
+    fun `different script identities cannot claim one generated name in the same import graph`() {
+        fun script(namespace: String) = KJvmCompiledScript(
+            null, ScriptCompilationConfiguration {
+                isSharedScript(true)
+                sharedScriptId("$namespace:common.kts")
+                sharedScriptFingerprint("revision")
+            }, "ConflictingShared", null, emptyList(), null,
+        )
+        val failure = assertFailsWith {
+            SharedScriptClasses.loadersFor(
+                listOf(script("conflict-one"), script("conflict-two")),
+                mapOf("ConflictingShared.class" to byteArrayOf()), javaClass.classLoader,
+            )
+        }
+        assertTrue(failure.message.orEmpty().contains("conflicting identities in one import graph"))
+    }
+
+    @Test
+    fun `serialized shared identity must match the jar manifest`() {
+        val script = KJvmCompiledScript(
+            null, ScriptCompilationConfiguration {
+                isSharedScript(true)
+                sharedScriptId("conflicting-metadata:common.kts")
+                sharedScriptFingerprint("compiled-revision")
+            }, "MismatchedShared", null, emptyList(), null,
+        )
+        val failure = assertFailsWith {
+            SharedScriptClasses.loadersFor(
+                listOf(script), emptyMap(), javaClass.classLoader,
+                mapOf("MismatchedShared" to SharedScriptRef(
+                    "MismatchedShared", ScriptId("conflicting-metadata", "common.kts"), "manifest-revision",
+                )),
+            )
+        }
+        assertTrue(failure.message.orEmpty().contains("Conflicting shared script metadata"))
+    }
+
+    private fun sharedScript(name: String) = KJvmCompiledScript(
+        null, ScriptCompilationConfiguration { isSharedScript(true) }, name, null, emptyList(), null,
+    )
+}
diff --git a/runtime/src/test/kotlin/UiBatchBoundsTest.kt b/runtime/src/test/kotlin/UiBatchBoundsTest.kt
new file mode 100644
index 000000000..91ca4bc5a
--- /dev/null
+++ b/runtime/src/test/kotlin/UiBatchBoundsTest.kt
@@ -0,0 +1,22 @@
+import ru.hollowhorizon.hollowengine.client.ui.render.UiBatchBounds
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class UiBatchBoundsTest {
+    @Test
+    fun `a quad between pending quads does not overlap them`() {
+        val bounds = UiBatchBounds()
+        bounds.add(0f, 0f, 10f, 10f)
+        bounds.add(100f, 100f, 110f, 110f)
+        assertFalse(bounds.overlaps(50f, 50f, 60f, 60f))
+        assertTrue(bounds.overlaps(105f, 105f, 120f, 120f))
+    }
+
+    @Test
+    fun `no quad is forgotten past the rects it keeps apart`() {
+        val bounds = UiBatchBounds()
+        repeat(500) { bounds.add(it * 20f, 0f, it * 20f + 10f, 10f) }
+        repeat(500) { assertTrue(bounds.overlaps(it * 20f + 1f, 1f, it * 20f + 2f, 2f), "quad $it") }
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt
new file mode 100644
index 000000000..f5b593ec5
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/editor/GizmoTrackballRotationTest.kt
@@ -0,0 +1,109 @@
+package ru.hollowhorizon.hollowengine.client.editor
+
+import net.minecraft.world.phys.Vec3
+import org.joml.Matrix4f
+import org.junit.jupiter.api.Test
+import org.lwjgl.glfw.GLFW
+import ru.hollowhorizon.hollowengine.client.utils.math.rotateBy
+import ru.hollowhorizon.hollowengine.common.utils.math.QuatF
+import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class GizmoTrackballRotationTest {
+    private val start = GizmoTransformValues(Vec3f.ZERO, QuatF.IDENTITY, Vec3f(1f, 1f, 1f))
+
+    @Test
+    fun `changing transform mode restores the pose and keeps one edit`() {
+        val session = GizmoSession(projector())
+        val target = Target(start)
+        val modes = listOf(GLFW.GLFW_KEY_G, GLFW.GLFW_KEY_R, GLFW.GLFW_KEY_S, GLFW.GLFW_KEY_G)
+        modes.forEachIndexed { index, key ->
+            val x = 450f + index * 40f
+            assertTrue(session.key(target, key, 0, x, 300f))
+            assertEquals(start, target.values)
+            session.moveTo(x + 40f, 340f, 0)
+            assertNotEquals(start, target.values)
+        }
+        assertEquals(1, target.begun)
+        assertEquals(0, target.ended)
+        session.cancel()
+        assertEquals(start, target.values)
+        assertEquals(1, target.ended)
+    }
+
+    @Test
+    fun `switching rotation modes stays in one edit and cancellation restores the target`() {
+        val session = GizmoSession(projector())
+        val target = Target(start)
+        assertTrue(session.key(target, GLFW.GLFW_KEY_R, 0, 450f, 300f))
+        session.moveTo(500f, 350f, 0)
+        assertTrue(session.key(target, GLFW.GLFW_KEY_R, 0, 500f, 350f))
+        session.moveTo(550f, 400f, 0)
+        assertNotEquals(start.rotation, target.values.rotation)
+        assertEquals(1, target.begun)
+        assertEquals(0, target.ended)
+        assertTrue(session.key(target, GLFW.GLFW_KEY_ESCAPE, 0, 550f, 400f))
+        assertEquals(start, target.values)
+        assertEquals(1, target.ended)
+        assertNull(session.keyboard)
+    }
+
+    @Test
+    fun `repeated rotate toggles the mode and axis constraints leave trackball`() {
+        val projector = projector()
+        val transform = GizmoKeyboardTransform(
+            GizmoEditMode.ROTATE, start, 450f, 300f, projector, GizmoGeometry(projector), GizmoManipulator(projector),
+        )
+        assertEquals(GizmoRotationMode.VIEW_ALIGNED, transform.rotationMode)
+        assertEquals(GizmoKeyResult.CHANGED, transform.key(GLFW.GLFW_KEY_R, 0))
+        assertEquals(GizmoRotationMode.TRACKBALL, transform.rotationMode)
+        assertNotEquals(start.rotation, assertNotNull(transform.update(500f, 350f, 0)).rotation)
+        transform.key(GLFW.GLFW_KEY_R, 0)
+        assertEquals(GizmoRotationMode.VIEW_ALIGNED, transform.rotationMode)
+        transform.key(GLFW.GLFW_KEY_R, 0)
+        transform.key(GLFW.GLFW_KEY_X, 0)
+        assertEquals(GizmoRotationMode.VIEW_ALIGNED, transform.rotationMode)
+        assertEquals(0, transform.axis)
+        transform.key(GLFW.GLFW_KEY_R, 0)
+        assertEquals(GizmoRotationMode.TRACKBALL, transform.rotationMode)
+        assertNull(transform.axis)
+    }
+
+    @Test
+    fun `trackball follows camera axes and recomputes from the original pose with snapping and fine motion`() {
+        val trackball = GizmoTrackballRotation(projector(), start, 400f, 300f, GizmoSnapping.MODEL)
+        assertEquals(start, trackball.update(400f, 300f, 0))
+        val horizontal = assertNotNull(trackball.update(550f, 300f, 0))
+        val front = Vec3f.Z_AXIS.rotateBy(horizontal.rotation)
+        assertEquals(1f, front.x, 0.0001f)
+        assertEquals(0f, front.z, 0.0001f)
+        trackball.update(550f, 450f, 0)
+        assertEquals(horizontal, trackball.update(550f, 300f, 0))
+        val fine = assertNotNull(trackball.update(550f, 300f, GLFW.GLFW_MOD_SHIFT))
+        val equivalent = assertNotNull(trackball.update(415f, 300f, 0))
+        assertEquals(equivalent.rotation, fine.rotation)
+        val snapped = assertNotNull(trackball.update(403f, 300f, GLFW.GLFW_MOD_CONTROL))
+        assertEquals(start.rotation, snapped.rotation)
+        assertEquals(start.translation, horizontal.translation)
+        assertEquals(start.scale, horizontal.scale)
+    }
+
+    private fun projector() = GizmoProjector().also {
+        it.capture(Matrix4f(), Matrix4f().perspective(1f, 800f / 600f, 0.1f, 100f), Vec3(0.0, 0.0, 5.0), 60f, 800f, 600f)
+    }
+
+    private class Target(private val start: GizmoTransformValues) : GizmoTarget {
+        override var values = start
+        var begun = 0
+        var ended = 0
+
+        override fun place(values: GizmoTransformValues, mode: GizmoEditMode) { this.values = values }
+        override fun restore() { values = start }
+        override fun beginEdit() { begun++ }
+        override fun endEdit() { ended++ }
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt
index e379f3277..09b36b7c0 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/timeline/ui/HollowTimelinePointerTest.kt
@@ -13,6 +13,26 @@ import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 
 class HollowTimelinePointerTest {
+    @Test
+    fun `double clicking a channel name selects its keys and scene selection marks its rows`() {
+        val (controller, curve) = timelineWithKeys(1f, 2f)
+        controller.groups.single().isSelected = true
+        HollowUiSurface().use { surface ->
+            surface.setContent { HollowTimelineEditor(controller, refresh = {}) }
+            val frame = surface.frame(800f, 500f, -1f, -1f, 0L)
+            val header = frame.nodes.single { it.id?.startsWith("header-timeline-property-") == true }
+            assertTrue("scene-selected" in header.tags)
+            val rect = frame.layout[header].rect
+            val x = rect.x + 40f
+            val y = rect.y + rect.height * 0.5f
+            surface.runtime.mouseClicked(x, y, GLFW.GLFW_MOUSE_BUTTON_LEFT)
+            surface.runtime.mouseReleased(x, y, GLFW.GLFW_MOUSE_BUTTON_LEFT)
+            surface.frame(800f, 500f, x, y, 100_000_000L)
+            surface.runtime.mouseClicked(x, y, GLFW.GLFW_MOUSE_BUTTON_LEFT)
+            assertEquals(curve.keyframes.toSet(), controller.selectedKeyframes.toSet())
+        }
+    }
+
     @Test
     fun `ctrl click on empty lane keeps the keyframe selection`() {
         val (controller, curve) = timelineWithKeys(1f, 2f)
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/history/UndoHistoryTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/history/UndoHistoryTest.kt
index a89c25ed8..b93b39377 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/history/UndoHistoryTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/history/UndoHistoryTest.kt
@@ -194,4 +194,22 @@ class UndoHistoryTest {
         assertEquals("a", value.current)
         assertEquals("x", held.current)
     }
+
+    @Test
+    fun `a rolled back transaction leaves the value as it was and nothing in the history`() {
+        val history = history()
+        val value = Value("a")
+        value.set("b", history)
+
+        history.begin()
+        history.begin()
+        value.set("c", history)
+        value.set("d", history)
+        history.rollback()
+
+        assertEquals("b", value.current)
+        assertEquals(1, history.steps.size)
+        assertTrue(history.undo())
+        assertEquals("a", value.current)
+    }
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompilerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompilerTest.kt
index 77b1b1456..0e1475db2 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompilerTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompilerTest.kt
@@ -279,4 +279,11 @@ class ShaderGraphCompilerTest {
         val graph = ShaderNodeLibrary.defaultSurface().copy(properties = listOf(ShaderGraphProperty("Speed", default = listOf(2f))))
         assertEquals(graph, ShaderGraphFormat.read(ShaderGraphFormat.write(graph)))
     }
+
+    @Test
+    fun `a link from a node the graph does not have is pointed at`() {
+        val code = ShaderGraphCompiler.compile(graph(links = listOf(ShaderGraphLink("missing", "Out", "out", SurfaceOutputs.COLOR))))
+
+        assertEquals(listOf(ShaderDiagnostic(ShaderProblem.MISSING_NODE, "out", "missing")), code.diagnostics)
+    }
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEditsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEditsTest.kt
index 881e5f568..fc1902de5 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEditsTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEditsTest.kt
@@ -1,5 +1,7 @@
 package ru.hollowhorizon.hollowengine.client.shadergraph
 
+import org.junit.jupiter.api.assertTimeoutPreemptively
+import java.time.Duration
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
@@ -47,8 +49,8 @@ class ShaderGraphEditsTest {
 
     @Test
     fun `deleting reroutes joins the link back up, however many of them are taken out at once`() {
-        val once = graph.withLink("a", "Out", "c", "A").withReroute(0, "r1", 0f, 0f)
-        val rerouted = once.withReroute(once.links.indexOfFirst { it.to == "c" }, "r2", 0f, 0f)
+        val once = graph.withLink("a", "Out", "c", "A").withReroute(ShaderGraphLink("a", "Out", "c", "A"), "r1", 0f, 0f)
+        val rerouted = once.withReroute(once.links.first { it.to == "c" }, "r2", 0f, 0f)
         assertEquals(3, rerouted.links.size)
 
         assertEquals(listOf(ShaderGraphLink("a", "Out", "c", "A")), rerouted.withoutNodes(setOf("r1", "r2")).links)
@@ -78,4 +80,25 @@ class ShaderGraphEditsTest {
         assertEquals(listOf("Rate"), renamed.properties.map { it.name })
         assertEquals("Rate", renamed.node("p")?.options?.get("property"))
     }
+
+    @Test
+    fun `deleting reroutes that feed each other in a loop ends`() {
+        val looped = ShaderGraph(
+            nodes = listOf(
+                ShaderGraphNode("r1", ShaderNodeLibrary.REROUTE),
+                ShaderGraphNode("r2", ShaderNodeLibrary.REROUTE),
+                ShaderGraphNode("a", "hollowengine:math/add"),
+            ),
+            links = listOf(
+                ShaderGraphLink("r2", ShaderNodeLibrary.REROUTE_OUTPUT, "r1", ShaderNodeLibrary.REROUTE_INPUT),
+                ShaderGraphLink("r1", ShaderNodeLibrary.REROUTE_OUTPUT, "r2", ShaderNodeLibrary.REROUTE_INPUT),
+                ShaderGraphLink("r1", ShaderNodeLibrary.REROUTE_OUTPUT, "a", "A"),
+            ),
+        )
+
+        val left = assertTimeoutPreemptively(Duration.ofSeconds(5)) { looped.withoutNodes(setOf("r1", "r2")) }
+
+        assertEquals(listOf("a"), left.nodes.map { it.id })
+        assertEquals(emptyList(), left.links)
+    }
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/UiLazyListTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/UiLazyListTest.kt
index a68c65ae3..ac10ca52e 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/UiLazyListTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/UiLazyListTest.kt
@@ -5,6 +5,27 @@ import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 
 class UiLazyListTest {
+    @Test
+    fun `rows sized in percent and grown children take the width of the list`() {
+        HollowUiSurface().use { surface ->
+            surface.setContent {
+                LazyColumn(modifier = Modifier.size(200.px, 100.px)) {
+                    items(3) { index ->
+                        Row(id = "row-$index", modifier = Modifier.size(100.percent)) {
+                            Box(modifier = Modifier.size(20.px, 20.px))
+                            Box(id = "grown-$index", modifier = Modifier.size(0.px, 20.px).grow(1f))
+                        }
+                    }
+                }
+            }
+
+            surface.frame(220f, 120f, -1f, -1f, 0L)
+            val frame = surface.frame(220f, 120f, -1f, -1f, 16_000_000L)
+            assertEquals(200f, frame.layout[frame.nodes.single { it.id == "row-1" }].rect.width)
+            assertEquals(180f, frame.layout[frame.nodes.single { it.id == "grown-1" }].rect.width)
+        }
+    }
+
     @Test
     fun `cross axis scroll exposes both ranges for wide lazy column rows`() {
         val state = LazyListState()
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorControllerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorControllerTest.kt
new file mode 100644
index 000000000..422416ea7
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/graph/GraphEditorControllerTest.kt
@@ -0,0 +1,149 @@
+package ru.hollowhorizon.hollowengine.client.ui.graph
+
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import org.lwjgl.glfw.GLFW
+import ru.hollowhorizon.hollowengine.client.shadergraph.*
+import ru.hollowhorizon.hollowengine.client.ui.BoxNode
+import ru.hollowhorizon.hollowengine.client.ui.UiEvent
+import ru.hollowhorizon.hollowengine.client.ui.UiEventKind
+import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument
+import ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph.ShaderGraphEditorModel
+import ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph.ShaderGraphScene
+import ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph.ShaderNodeLayout
+import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/** The controller over a material graph, the way the editor drives it. */
+class GraphEditorControllerTest {
+    private val nodes = listOf(
+        ShaderGraphNode("a", "hollowengine:math/add"),
+        ShaderGraphNode("b", "hollowengine:math/add", x = 300f),
+        ShaderGraphNode("c", "hollowengine:math/add", x = 600f),
+        ShaderGraphNode("out", ShaderNodeLibrary.SURFACE_OUTPUT, x = 900f),
+    )
+
+    @ParameterizedTest
+    @ValueSource(ints = [GLFW.GLFW_MOD_CONTROL, GLFW.GLFW_MOD_SHIFT])
+    fun `deselecting the last node closes the gesture and keeps undo usable`(modifiers: Int) {
+        val fixture = Fixture(ShaderGraph(nodes = nodes))
+        fixture.document.edit { it.withNodeAt("b", 320f, 20f) }
+        fixture.refresh()
+        fixture.controller.select(GraphSelection.of("a"))
+
+        fixture.controller.pressNode("a", GraphNodeGesture(0f, 0f, 0f, 0f, modifiers))
+        fixture.controller.releaseNode()
+
+        assertTrue(fixture.controller.selection.nodes.isEmpty())
+        assertTrue(fixture.document.history.undo(), "A selection-only press must not leave an open undo transaction")
+        assertEquals(300f, fixture.document.graph.node("b")?.x)
+    }
+
+    @Test
+    fun `select all includes nodes hidden in collapsed groups`() {
+        val fixture = Fixture(
+            ShaderGraph(
+                nodes = nodes,
+                groups = listOf(ShaderGraphGroup("group", "Group", listOf("a", "b"), collapsed = true)),
+            )
+        )
+
+        assertTrue(fixture.controller.handleKey(key(GLFW.GLFW_KEY_A, GLFW.GLFW_MOD_CONTROL)))
+
+        assertEquals(nodes.map { it.id }.toSet(), fixture.controller.selection.nodes)
+    }
+
+    @Test
+    fun `escape while rewiring restores the detached link`() {
+        val original = ShaderGraphLink("a", "Out", "b", "A")
+        val fixture = Fixture(ShaderGraph(nodes = nodes, links = listOf(original)))
+        val input = fixture.controller.model.scene.pins.first { it.node == "b" && it.name == "A" && !it.output }
+        fixture.controller.pressPin(input, 0f, 0f)
+        assertTrue(fixture.document.graph.links.isEmpty(), "The test must actually pick up the existing wire")
+        fixture.refresh()
+
+        assertTrue(fixture.controller.handleKey(key(GLFW.GLFW_KEY_ESCAPE)))
+
+        assertEquals(
+            listOf(original),
+            fixture.document.graph.links,
+            "Escape should cancel the rewire, including its eager detach"
+        )
+        assertFalse(fixture.document.history.canUndo, "A cancelled gesture should not become an edit")
+    }
+
+    @Test
+    fun `creating a reroute from the link menu is one undo step`() {
+        val fixture = Fixture(ShaderGraph(nodes = nodes))
+        val pending = GraphPendingLink("a", "Out", true, 400f, 400f, 400f, 400f)
+        val items = fixture.controller.model.offerNodes(pending, 400f, 400f)
+
+        items.first().onClick()
+        assertEquals(1, fixture.document.graph.links.size)
+        assertEquals(nodes.size + 1, fixture.document.graph.nodes.size)
+        assertTrue(fixture.document.history.undo())
+
+        assertEquals(
+            nodes, fixture.document.graph.nodes, "Undo must remove the newly created node together with its wire"
+        )
+        assertTrue(fixture.document.graph.links.isEmpty())
+    }
+
+    @Test
+    fun `undoing another wire removal keeps the selected wire identity`() {
+        val first = ShaderGraphLink("a", "Out", "b", "A")
+        val selected = ShaderGraphLink("a", "Out", "c", "A")
+        val last = ShaderGraphLink("b", "Out", "c", "B")
+        val fixture = Fixture(ShaderGraph(nodes = nodes, links = listOf(first, selected, last)))
+        fixture.document.edit { it.withoutLink(first) }
+        fixture.refresh()
+        fixture.controller.select(GraphSelection.ofLink(fixture.controller.model.scene.links.first().key))
+
+        assertTrue(fixture.document.history.undo())
+        fixture.refresh()
+        fixture.controller.deleteSelection()
+
+        assertEquals(
+            listOf(first, last), fixture.document.graph.links, "Delete must still act on the wire selected before undo"
+        )
+    }
+
+    @Test
+    fun `ordinary node selection closes its gesture and keeps undo usable`() {
+        val fixture = Fixture(ShaderGraph(nodes = nodes))
+        fixture.document.edit { it.withNodeAt("b", 320f, 20f) }
+        fixture.refresh()
+
+        fixture.controller.pressNode("a", GraphNodeGesture(0f, 0f, 0f, 0f, 0))
+        fixture.controller.releaseNode()
+
+        assertTrue(fixture.document.history.undo())
+        assertEquals(300f, fixture.document.graph.node("b")?.x)
+    }
+
+    private fun key(code: Int, modifiers: Int = 0) = UiKeyInput(
+        UiEvent(UiEventKind.KEY_PRESSED, BoxNode(), key = code, modifiers = modifiers),
+    )
+
+    private class Fixture(graph: ShaderGraph) {
+        val document = HollowIdeShaderGraphDocument(ShaderGraphFormat.write(graph).toByteArray())
+        val controller = GraphEditorController("test-graph", GraphEditorState())
+
+        init {
+            refresh()
+        }
+
+        fun refresh() {
+            val graph = document.graph
+            val code = ShaderGraphCompiler.compile(graph)
+            val boxes = graph.nodes.associate { node ->
+                node.id to ShaderNodeLayout.of(graph, node, checkNotNull(ShaderNodeTypes.of(node.type)), code.types)
+            }
+            controller.model =
+                ShaderGraphEditorModel(document, code, ShaderGraphScene.of(graph, boxes), controller::select)
+        }
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt
new file mode 100644
index 000000000..f7d406cbc
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/EditorStructureShiftTest.kt
@@ -0,0 +1,37 @@
+package ru.hollowhorizon.hollowengine.client.ui.ide
+
+import ru.hollowhorizon.hollowengine.common.scripting.ide.EditorStructure
+import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarker
+import ru.hollowhorizon.hollowengine.common.scripting.ide.GutterMarkerKind
+import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptRunKind
+import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptRunTarget
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** The gutter keeps its markers between an edit and the analysis of the edited text. */
+class EditorStructureShiftTest {
+    private val original = "fun first() {}\n@State suspend fun second() {}\nfun third() {}"
+    private val structure = EditorStructure(
+        markers = listOf(
+            GutterMarker(original.indexOf("first"), GutterMarkerKind.EVENT),
+            GutterMarker(original.indexOf("third"), GutterMarkerKind.OVERRIDE),
+        ),
+        runTargets = listOf(ScriptRunTarget(original.indexOf("second"), ScriptRunKind.NODE, "second")),
+    )
+
+    @Test
+    fun `markers before and after an edit move with the text`() {
+        val edited = original.replace("fun first() {}", "fun first() { work() }")
+        val shifted = shiftEditorStructureThroughEdit(original, edited, structure)
+        assertEquals(listOf(edited.indexOf("first"), edited.indexOf("third")), shifted.markers.map { it.offset })
+        assertEquals(edited.indexOf("second"), shifted.runTargets.single().offset)
+    }
+
+    @Test
+    fun `a marker the edit went through waits for the next analysis`() {
+        val edited = original.replace("fun third", "fun rd")
+        val shifted = shiftEditorStructureThroughEdit(original, edited, structure)
+        assertEquals(listOf(GutterMarkerKind.EVENT), shifted.markers.map { it.kind })
+        assertEquals(1, shifted.runTargets.size)
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt
index 7145dbd4e..d7f9e8a95 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/IdeContextCompletionsTest.kt
@@ -25,6 +25,13 @@ class IdeContextCompletionsTest {
         assertEquals(1, contextSnippetCompletions(Vec3.ZERO, null, 0).size)
     }
 
+    @Test
+    fun `snippets are left out where their type does not fit`() {
+        val onlyItems = contextSnippetCompletions(Vec3.ZERO, "minecraft:diamond", 1, listOf(ItemSnippetType))
+        assertEquals(listOf("item(\"minecraft:diamond\", 1)"), onlyItems.map { it.insertText })
+        assertTrue(contextSnippetCompletions(Vec3.ZERO, "minecraft:diamond", 1, emptyList()).isEmpty())
+    }
+
     @Test
     fun `item specifications retain changed and removed components and escape Kotlin strings`() {
         val patch = CompoundTag().apply {
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocumentTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocumentTest.kt
new file mode 100644
index 000000000..f60f0eb2b
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeAnimatorDocumentTest.kt
@@ -0,0 +1,31 @@
+package ru.hollowhorizon.hollowengine.client.ui.ide.files
+
+import ru.hollowhorizon.hollowengine.common.models.GraphPoint
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class HollowIdeAnimatorDocumentTest {
+    private val broken = byteArrayOf(1, 2, 3)
+
+    @Test
+    fun `an unreadable animator refuses edits and keeps its bytes`() {
+        val document = HollowIdeAnimatorDocument(broken)
+
+        document.edit { it.copy(layout = mapOf("layer/a" to GraphPoint(1f, 1f))) }
+
+        assertTrue(document.readOnly, "A decode failure must not become an editable empty animator")
+        assertFalse(document.isModified)
+        assertContentEquals(broken, document.encode())
+    }
+
+    @Test
+    fun `reloading a repaired file makes it editable again`() {
+        val document = HollowIdeAnimatorDocument(broken)
+
+        document.reload(HollowIdeAnimatorDocument(ByteArray(0)).encode())
+
+        assertFalse(document.readOnly)
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt
new file mode 100644
index 000000000..f2f1809c3
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimeOfDayValueFormatterTest.kt
@@ -0,0 +1,15 @@
+package ru.hollowhorizon.hollowengine.client.ui.ide.timeline
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TimeOfDayValueFormatterTest {
+    @Test
+    fun `time of day formatter follows the minecraft clock`() {
+        assertEquals("06:00", TimeOfDayValueFormatter.format(0f))
+        assertEquals("12:00", TimeOfDayValueFormatter.format(6_000f))
+        assertEquals("00:00", TimeOfDayValueFormatter.format(18_000f))
+        assertEquals("05:59", TimeOfDayValueFormatter.format(23_999f))
+        assertEquals("07:00", TimeOfDayValueFormatter.format(25_000f))
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt
new file mode 100644
index 000000000..3b647ecde
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineSelectionTest.kt
@@ -0,0 +1,63 @@
+package ru.hollowhorizon.hollowengine.client.ui.ide.timeline
+
+import org.junit.jupiter.api.Test
+import org.lwjgl.glfw.GLFW
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class TimelineSelectionTest {
+    @Test
+    fun `select all includes folded groups but skips locked and unlisted channels`() {
+        val timeline = TimelineController()
+        val folded = property(timeline, "folded")
+        timeline.group(listOf("folded")).isCollapsed = true
+        val locked = property(timeline, "locked")
+        timeline.group(listOf("locked")).isLocked = true
+        val hidden = property(timeline, "hidden").also { it.isListed = false }
+        val channel = property(timeline, "channel").also { it.curves.single().isListed = false }
+
+        assertTrue(TimelineKeys.handle(timeline, GLFW.GLFW_KEY_A, GLFW.GLFW_MOD_CONTROL))
+        assertEquals(folded.keyframes.toSet(), timeline.selectedKeyframes.toSet())
+        assertTrue(listOf(locked, hidden, channel).none { it.keyframes.any(timeline::isSelected) })
+    }
+
+    @Test
+    fun `column selection starts at the playhead and extends all selected times across tracks`() {
+        val timeline = TimelineController()
+        val first = property(timeline, "first")
+        val second = property(timeline, "second")
+        val nearby = Keyframe(1f + ChannelCurve.KEY_TIME_EPSILON * 0.5f, 0f)
+        second.curves.single().keyframes += nearby
+        timeline.applyCurrentTime(1f)
+
+        assertTrue(TimelineKeys.handle(timeline, GLFW.GLFW_KEY_K, 0))
+        assertEquals(setOf(first.keyframes[0], second.keyframes[0], nearby), timeline.selectedKeyframes.toSet())
+
+        timeline.select(listOf(first.keyframes[0], first.keyframes[2]), additive = false)
+        timeline.selectKeyColumns()
+        assertEquals(
+            setOf(first.keyframes[0], first.keyframes[2], second.keyframes[0], second.keyframes[2], nearby),
+            timeline.selectedKeyframes.toSet(),
+        )
+    }
+
+    @Test
+    fun `graph commands respect curve focus and track selection does not select other channels`() {
+        val timeline = TimelineController()
+        val first = property(timeline, "first")
+        val second = property(timeline, "second")
+        timeline.viewMode = TimelineViewMode.CURVES
+        timeline.focusCurves(first.curves, additive = false)
+
+        timeline.selectAllKeys()
+        assertEquals(first.keyframes.toSet(), timeline.selectedKeyframes.toSet())
+
+        timeline.selectTrackKeys(second.curves)
+        assertEquals(second.keyframes.toSet(), timeline.selectedKeyframes.toSet())
+    }
+
+    private fun property(timeline: TimelineController, name: String): AnimProperty =
+        timeline.addProperty(listOf(name), AnimProperty(name, name, FloatPropertyType(), 0f)).also { property ->
+            property.curves.single().keyframes.addAll(listOf(1f, 2f, 3f).map { Keyframe(it, 0f) })
+        }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransformTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransformTest.kt
index c23c54f40..2197692f0 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransformTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/CurveTransformTest.kt
@@ -12,6 +12,26 @@ class CurveTransformTest {
     /** A hundred pixels a second and a hundred a unit of value, the value 0 in the middle of a 200 px graph. */
     private val view = CurveView(pxPerSec = 100f, start = 0f, center = 0f, span = 2f, height = 200f)
 
+    @Test
+    fun `switching modes restores keys and starts at the current pointer`() {
+        val controller = TimelineController()
+        val key = Keyframe(1f, 0.5f)
+        curve(controller, key)
+        controller.select(listOf(key), additive = false)
+        var transform = assertNotNull(CurveTransform.start(controller, GizmoEditMode.TRANSLATE, view, 200f, 100f))
+        for (mode in listOf(GizmoEditMode.ROTATE, GizmoEditMode.SCALE, GizmoEditMode.TRANSLATE)) {
+            transform.update(300f, 150f)
+            transform = transform.switchMode(mode)
+            assertEquals(mode, transform.mode)
+            assertEquals(1f, key.time)
+            assertEquals(0.5f, key.value)
+            transform.update(300f, 150f)
+            assertEquals(1f, key.time, 1e-4f)
+            assertEquals(0.5f, key.value, 1e-4f)
+        }
+        transform.key(GLFW.GLFW_KEY_ESCAPE, 0)
+    }
+
     private fun curve(controller: TimelineController, vararg keys: Keyframe): ChannelCurve {
         controller.workAreaEnd = 10f
         val property = controller.addProperty(listOf("Test"), AnimProperty("test.value", "Value", FloatPropertyType(), 0f))
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt
new file mode 100644
index 000000000..65cc867c4
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/TimelineRowsTest.kt
@@ -0,0 +1,37 @@
+package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui
+
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelSampling
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelSpec
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.FloatPropertyType
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.PropertyType
+import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController
+import ru.hollowhorizon.hollowengine.common.utils.Color
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TimelineRowsTest {
+    @Test
+    fun `discrete tracks are omitted from curve editor rows`() {
+        val timeline = TimelineController()
+        val continuous = timeline.addProperty(listOf("node"), AnimProperty("size", "Size", FloatPropertyType(), 1f))
+        val discrete = timeline.addProperty(listOf("node"), AnimProperty("mode", "Mode", StepPropertyType, 0f))
+
+        val rows = timelineRows(timeline, curveEditorOnly = true)
+
+        assertTrue(rows.any { it.property === continuous })
+        assertFalse(rows.any { it.property === discrete })
+    }
+
+    private object StepPropertyType : PropertyType {
+        override val id = "step"
+        override val channels = listOf(ChannelSpec("Mode", Color("FFFFFF"), sampling = ChannelSampling.DISCRETE))
+
+        override fun decompose(value: Float, into: FloatArray) {
+            into[0] = value
+        }
+
+        override fun compose(values: FloatArray): Float = values[0]
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/DropFeedbackTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/DropFeedbackTest.kt
index 4f5de965f..0b5a32865 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/DropFeedbackTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/DropFeedbackTest.kt
@@ -7,6 +7,29 @@ import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect
 import kotlin.test.*
 
 class DropFeedbackTest {
+    @Test
+    fun `drop preview is cleared on leaving rejection cancellation and target disposal`() {
+        val state = UiDragAndDropState()
+        var exits = 0
+        val target = UiDropTarget("editor", UiRect(0f, 0f, 100f, 100f), onDragExit = { exits++ })
+        val unregister = state.register(target)
+        state.begin(UiDragItem("file"), 10f, 10f)
+        state.move(110f, 10f)
+        assertEquals(1, exits)
+        state.move(10f, 10f)
+        target.accepts = { false }
+        state.move(10f, 10f)
+        assertEquals(2, exits)
+        target.accepts = { true }
+        state.move(10f, 10f)
+        state.cancel()
+        assertEquals(3, exits)
+        state.begin(UiDragItem("file"), 10f, 10f)
+        unregister()
+        assertEquals(4, exits)
+        assertFalse(state.canDrop)
+    }
+
     @Test
     fun `invalid row blocks the root and leaving clears folder feedback`() {
         val state = UiDragAndDropState()
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt
index b8589f2b2..1008b5d4f 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/FocusTest.kt
@@ -1,11 +1,54 @@
 package ru.hollowhorizon.hollowengine.client.ui.input
 
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
 import org.junit.jupiter.api.Test
 import org.lwjgl.glfw.GLFW
 import ru.hollowhorizon.hollowengine.client.ui.*
 import kotlin.test.assertEquals
 
 class FocusTest {
+    @Test
+    fun `toolbar clicks preserve the viewport focus and an active panel gets first use of keys`() {
+        val log = mutableListOf()
+        val revision = mutableStateOf(0)
+        HollowUiSurface().use { surface ->
+            surface.setContent {
+                Row(modifier = Modifier.focusScope()) {
+                    Box(id = "preview-scope", modifier = Modifier.size(100.px, 100.px).focusScope()) {
+                        key(revision.value) {
+                            Box(id = "viewport", modifier = Modifier.size(100.px, 100.px).focus().onKeyInput {
+                                log += "viewport"
+                                it.consume()
+                            })
+                        }
+                    }
+                    Box(id = "toolbar", modifier = Modifier.size(100.px, 100.px)
+                        .input(clickable = true).onClick { revision.value++; it.consume() })
+                    Box(id = "timeline", modifier = Modifier.size(100.px, 100.px).focusScope()
+                        .input(clickable = true).onKeyInput {
+                            log += "timeline"
+                            it.consume()
+                        })
+                }
+            }
+            val runtime = surface.runtime
+            surface.frame(300f, 100f, -1f, -1f, 0L)
+            runtime.mouseClicked(50f, 50f, GLFW.GLFW_MOUSE_BUTTON_LEFT)
+            assertEquals("viewport", runtime.focusedKey)
+            runtime.mouseClicked(150f, 50f, GLFW.GLFW_MOUSE_BUTTON_LEFT)
+            surface.frame(300f, 100f, -1f, -1f, 16_000_000L)
+            assertEquals("viewport", runtime.focusedKey)
+            runtime.keyPressed(GLFW.GLFW_KEY_R, 0, 0)
+            assertEquals(listOf("viewport"), log)
+
+            log.clear()
+            runtime.mouseClicked(250f, 50f, GLFW.GLFW_MOUSE_BUTTON_LEFT)
+            runtime.keyPressed(GLFW.GLFW_KEY_G, 0, 0)
+            assertEquals(listOf("timeline"), log)
+        }
+    }
+
     private fun keyLoggingBox(id: String, layer: Int, log: MutableList, consume: Boolean): BoxNode {
         // A focus scope is always key-active while composed, so it is a self-contained multi-focus target.
         val mod = Modifier.focusScope().layer(layer).onKeyInput { input ->
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/LayoutReuseTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/LayoutReuseTest.kt
new file mode 100644
index 000000000..6a555d438
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/LayoutReuseTest.kt
@@ -0,0 +1,91 @@
+package ru.hollowhorizon.hollowengine.client.ui.layout
+
+import org.junit.jupiter.api.Test
+import ru.hollowhorizon.hollowengine.client.ui.*
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotSame
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class LayoutReuseTest {
+    private val leaf = BoxNode(modifiers = listOf(Modifier.size(20.px, 10.px)))
+    private val still = column(leaf)
+    private val grown = BoxNode(modifiers = listOf(Modifier.size(20.px, 10.px)))
+    private val changing = column(grown)
+    private val root = column(changing, still)
+    private val runtime = HollowUiRuntime()
+
+    private fun column(vararg children: BoxNode) = BoxNode(measurePolicy = UiMeasurePolicies.Column).also { node ->
+        children.forEach {
+            node.children.add(it)
+            it.layoutState.attachTo(node)
+        }
+    }
+
+    private fun frame() = runtime.frame(root, 200f, 200f, -1f, -1f, 0L).layout
+
+    private fun BoxNode.resize(height: Int) {
+        modifiers.clear()
+        modifiers.add(Modifier.size(20.px, height.px))
+    }
+
+    @Test
+    fun `a subtree pushed down by its sibling is placed again`() {
+        val before = frame()
+
+        grown.resize(30)
+        val after = frame()
+
+        assertEquals(30f, after[grown].rect.height)
+        assertNotSame(before[still], after[still])
+        assertEquals(before[leaf].rect.y + 20f, after[leaf].rect.y)
+    }
+
+    @Test
+    fun `a child raised to another layer moves up the draw order`() {
+        frame()
+
+        changing.modifiers.add(Modifier.layer(1))
+
+        assertEquals(listOf(still, changing), frame().childrenOf(root))
+    }
+
+    @Test
+    fun `changing the end of a column leaves what is above it`() {
+        val before = frame()
+        val above = before[changing]
+
+        leaf.resize(30)
+        val after = frame()
+
+        assertSame(above, after[changing])
+        assertSame(before[grown], after[grown])
+        assertEquals(30f, after[leaf].rect.height)
+        assertEquals(before.traversalOrder.toSet(), after.traversalOrder.toSet())
+        assertEquals(listOf(grown), after.childrenOf(changing))
+    }
+
+    @Test
+    fun `a removed child leaves the layout`() {
+        frame()
+
+        still.children.remove(leaf)
+        leaf.layoutState.attachTo(null)
+        still.layoutState.invalidateLayout()
+        val after = frame()
+
+        assertFalse(leaf in after.nodes)
+        assertEquals(emptyList(), after.childrenOf(still))
+    }
+
+    @Test
+    fun `a node that starts to need a layer is placed again`() {
+        leaf.resize(10)
+        assertFalse(frame()[still].needsFramebuffer)
+
+        still.modifiers.add(Modifier.opacity(0.5f))
+
+        assertTrue(frame()[still].needsFramebuffer)
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/StyleInvalidationTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/StyleInvalidationTest.kt
index 70720d23f..746b05d52 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/StyleInvalidationTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/StyleInvalidationTest.kt
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test
 import ru.hollowhorizon.hollowengine.client.ui.*
 import ru.hollowhorizon.hollowengine.client.ui.style.UiModifierResolver
 import ru.hollowhorizon.hollowengine.client.ui.text.Bold
+import kotlin.test.assertFalse
 import kotlin.test.assertNotEquals
 import kotlin.test.assertNotSame
 import kotlin.test.assertSame
@@ -76,6 +77,11 @@ class StyleInvalidationTest {
         runtime.frame(root, 200f, 200f, 10f, 10f, 0L)
         assertTrue(UiState.HOVER in child.effectiveStates(), "the hovered leaf is hovered")
         assertTrue(UiState.HOVER in parent.effectiveStates(), "its ancestor is hovered too")
+
+        runtime.frame(root, 200f, 200f, 150f, 150f, 0L)
+        runtime.frame(root, 200f, 200f, 150f, 150f, 0L)
+        assertFalse(UiState.HOVER in child.effectiveStates(), "the pointer left the leaf")
+        assertFalse(UiState.HOVER in parent.effectiveStates(), "and its ancestor")
     }
 
     @Test
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/LayerOverflowTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/LayerOverflowTest.kt
index 2d82a9778..ca5b82dc3 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/LayerOverflowTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/LayerOverflowTest.kt
@@ -20,11 +20,11 @@ class LayerOverflowTest {
         ).also { it.children.add(plate) }
         val root = BoxNode(measurePolicy = UiMeasurePolicies.Column).also { it.children.add(panel) }
 
-        val overflow = layoutOf(root).layerOverflows()[panel]
+        val overflow = layoutOf(root).layerOverflow(panel)
 
         assertEquals(30f, overflow, "the plate sits 30px above the panel it belongs to")
         assertTrue(
-            layerPadding(UiFilterChain.Empty, overflow!!) >= 30f,
+            layerPadding(UiFilterChain.Empty, overflow) >= 30f,
             "the reserved padding has to cover the overhang, not just the guard band",
         )
     }
@@ -46,16 +46,8 @@ class LayerOverflowTest {
 
         assertEquals(
             0f,
-            layoutOf(root).layerOverflows()[panel],
+            layoutOf(root).layerOverflow(panel),
             "content the viewport clips away never reaches the panel's layer",
         )
     }
-
-    @Test
-    fun `a layout with nothing to composite asks for nothing`() {
-        val child = BoxNode(modifiers = listOf(Modifier.size(40.px, 16.px)))
-        val root = BoxNode(measurePolicy = UiMeasurePolicies.Column).also { it.children.add(child) }
-
-        assertEquals(emptyMap(), layoutOf(root).layerOverflows())
-    }
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAtlasSizeTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAtlasSizeTest.kt
new file mode 100644
index 000000000..833a7f19a
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAtlasSizeTest.kt
@@ -0,0 +1,30 @@
+package ru.hollowhorizon.hollowengine.client.ui.render
+
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class UiAtlasSizeTest {
+    private val nodes = List(3) { UiLayerRequest(225, 60) } + List(6) { UiLayerRequest(138, 138) }
+
+    @Test
+    fun `atlas takes little more than its layers need`() {
+        val size = atlasSizeFor(nodes, null, 16_384)
+
+        assertTrue(size.width * size.height < 1024 * 512, "$size")
+    }
+
+    @Test
+    fun `atlas keeps what it has while the layers fit`() {
+        assertEquals(UiAtlasSize(2048, 1024), atlasSizeFor(nodes, UiAtlasSize(2048, 1024), 16_384))
+    }
+
+    @Test
+    fun `atlas is oversized only when it is large and mostly empty`() {
+        assertTrue(isAtlasOversized(nodes, 5010, 3884))
+        assertTrue(isAtlasOversized(emptyList(), 5010, 3884))
+        assertFalse(isAtlasOversized(emptyList(), 512, 512))
+        assertFalse(isAtlasOversized(List(8) { UiLayerRequest(1000, 1000) }, 4000, 2000))
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatchTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatchTest.kt
index 804a6d264..5c7a1d9a3 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatchTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiPathTileBatchTest.kt
@@ -218,13 +218,22 @@ class UiPathTileBatchTest {
     }
 
     @Test
-    fun `stable raster switches from compute preference to baked cpu after observation`() {
+    fun `new raster goes to compute only in a large batch and to baked cpu after observation`() {
         val batch = UiPathTileBatch()
 
         batch.append(command(square), UiMatrix4.identity())
+        assertTrue(batch.prefersBakedCpu)
+        batch.clear()
+        val other = GenericShape { size ->
+            moveTo(0f, 0f)
+            lineTo(size.width, 0f)
+            lineTo(0f, size.height)
+            close()
+        }
+        repeat(600) { batch.append(command(other), UiMatrix4.identity()) }
         assertTrue(!batch.prefersBakedCpu)
         batch.clear()
-        batch.append(command(square), UiMatrix4.translation(20f, 10f, 0f))
+        repeat(600) { batch.append(command(other), UiMatrix4.translation(20f, 10f, 0f)) }
 
         assertTrue(batch.prefersBakedCpu)
     }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/AncestorStateSelectorTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/AncestorStateSelectorTest.kt
index c595f6e12..b2f36f47a 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/AncestorStateSelectorTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/AncestorStateSelectorTest.kt
@@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.client.ui.style
 import org.junit.jupiter.api.Test
 import ru.hollowhorizon.hollowengine.client.ui.*
 import kotlin.test.assertEquals
+import kotlin.test.assertSame
 
 /**
  * `.parent:hover .child` - a rule whose *ancestor* carries the state. The child has no state
@@ -45,6 +46,20 @@ class AncestorStateSelectorTest {
         )
     }
 
+    @Test
+    fun `a state no selector asks of a node leaves the nodes below it alone`() {
+        val (button, icon) = tree()
+        val panel = BoxNode().also { it.children.add(button) }
+        button.layoutState.attachTo(panel)
+        val resolver = UiModifierResolver(stylesheet = sheet)
+        resolver.resolve(panel, animate = false)
+        val style = icon.resolvedSnapshot
+
+        panel.setRuntimeStates(setOf(UiState.HOVER))
+        resolver.resolve(panel, animate = false)
+        assertSame(style, icon.resolvedSnapshot)
+    }
+
     @Test
     fun `an ancestor state rule stacks like a state rule, not like a base rule`() {
         val stacking = compileHss(
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/StyleAncestorCacheTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/StyleAncestorCacheTest.kt
new file mode 100644
index 000000000..6d38146f1
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/StyleAncestorCacheTest.kt
@@ -0,0 +1,51 @@
+package ru.hollowhorizon.hollowengine.client.ui.style
+
+import org.junit.jupiter.api.Test
+import ru.hollowhorizon.hollowengine.client.ui.*
+import kotlin.test.assertEquals
+
+class StyleAncestorCacheTest {
+    private val sheet = compileHss(
+        """
+        .icon { tint: #808080; }
+        .button.loud .icon { tint: #FFFFFF; }
+        """.trimIndent(),
+    )
+
+    private fun tree(): Pair {
+        val icon = BoxNode(tags = listOf("icon"))
+        val button = BoxNode(tags = listOf("button")).also { it.children.add(icon) }
+        icon.layoutState.attachTo(button)
+        return button to icon
+    }
+
+    private fun recomputed(resolver: UiModifierResolver, root: UiNode): Int {
+        val profiler = UiProfiler().apply { enabled = true }
+        val frame = checkNotNull(profiler.beginFrame())
+        resolver.resolve(root, animate = false, profile = frame)
+        return frame.styleRecomputedNodes
+    }
+
+    @Test
+    fun `a modifier of an ancestor leaves the styles under it alone`() {
+        val (button, _) = tree()
+        val resolver = UiModifierResolver(stylesheet = sheet)
+        resolver.resolve(button, animate = false)
+
+        button.modifiers += Modifier.translate(4f, 0f)
+
+        assertEquals(1, recomputed(resolver, button), "only the changed node is restyled")
+    }
+
+    @Test
+    fun `a tag of an ancestor restyles what selectors find under it`() {
+        val (button, icon) = tree()
+        val resolver = UiModifierResolver(stylesheet = sheet)
+        resolver.resolve(button, animate = false)
+
+        button.tags += "loud"
+        resolver.resolve(button, animate = false)
+
+        assertEquals(UiColor.White, icon.resolvedSnapshot.tint)
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/CodeInsightPopupLayoutTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/CodeInsightPopupLayoutTest.kt
index 6a6018f65..e54a59702 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/CodeInsightPopupLayoutTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/CodeInsightPopupLayoutTest.kt
@@ -55,7 +55,7 @@ class CodeInsightPopupLayoutTest {
                 EditableTextField(
                     state = state,
                     modifier = Modifier.size(620.px, 40.px),
-                    signatureHelpProvider = provider,
+                    insight = UiCodeInsight(signatureHelp = provider),
                 )
             }
 
@@ -102,7 +102,7 @@ class CodeInsightPopupLayoutTest {
                 EditableTextField(
                     state = state,
                     modifier = Modifier.size(620.px, 90.px),
-                    signatureHelpProvider = provider,
+                    insight = UiCodeInsight(signatureHelp = provider),
                 )
             }
 
@@ -157,7 +157,7 @@ class CodeInsightPopupLayoutTest {
                 EditableTextField(
                     state = state,
                     modifier = Modifier.size(360.px, 100.px),
-                    hoverInfoProvider = provider,
+                    insight = UiCodeInsight(hover = provider),
                 )
             }
 
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextPresentationTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextPresentationTest.kt
index 40ce4971b..7585e2ef3 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextPresentationTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextPresentationTest.kt
@@ -341,8 +341,7 @@ class EditableTextPresentationTest {
                 compositions++
                 EditableTextField(
                     state = state,
-                    syntaxHighlighter = highlighter,
-                    inlayHintsProvider = inlayProvider,
+                    insight = UiCodeInsight(highlighter = highlighter, inlayHintsProvider = inlayProvider),
                     modifier = Modifier.size(240.px, 120.px),
                 )
             }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditorGutterFoldingTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditorGutterFoldingTest.kt
index 685327524..c1a2fc2a6 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditorGutterFoldingTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditorGutterFoldingTest.kt
@@ -221,7 +221,7 @@ class EditorGutterFoldingTest {
         }
         HollowUiSurface(stylesheet = compileHss(source)).use { surface ->
             surface.setContent {
-                UiCodeEditor(text, {}, state = state, folds = listOf(fold), id = "editor",
+                UiCodeEditor(text, {}, state = state, view = UiCodeView(folds = listOf(fold)), id = "editor",
                     modifier = Modifier.size(500.px, 250.px))
             }
             var time = 0L
@@ -256,8 +256,8 @@ class EditorGutterFoldingTest {
         HollowUiSurface(stylesheet = compileHss(source)).use { surface ->
             val state = TextFieldState(text, multiline = true)
             surface.setContent {
-                UiCodeEditor(text, {}, state = state, folds = folds, id = "editor",
-                    gutterMarkers = listOf(UiGutterMarker("run", 0, "play.svg", "Run", onClick = {})),
+                UiCodeEditor(text, {}, state = state, id = "editor",
+                    view = UiCodeView(folds, listOf(UiGutterMarker("run", 0, "play.svg", "Run", onClick = {}))),
                     modifier = Modifier.size(500.px, 250.px))
             }
             var time = 0L
@@ -294,8 +294,8 @@ class EditorGutterFoldingTest {
         var clicks = 0
         HollowUiSurface().use { surface ->
             surface.setContent {
-                UiCodeEditor(text, {}, state = state, folds = folds, id = "editor",
-                    gutterMarkers = listOf(UiGutterMarker("run", 0, "run.svg", "Run", onClick = { clicks++ })),
+                UiCodeEditor(text, {}, state = state, id = "editor",
+                    view = UiCodeView(folds, listOf(UiGutterMarker("run", 0, "run.svg", "Run", onClick = { clicks++ }))),
                     modifier = Modifier.size(500.px, 250.px))
             }
             var tick = 0L
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeEditingTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeEditingTest.kt
new file mode 100644
index 000000000..27cf4c124
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldCodeEditingTest.kt
@@ -0,0 +1,204 @@
+package ru.hollowhorizon.hollowengine.client.ui.widgets
+
+import org.junit.jupiter.api.Test
+import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinCodeSyntax
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TextFieldCodeEditingTest {
+    private fun field(text: String, caret: Int = text.length, size: Int? = 4) =
+        TextFieldState(text, caret, multiline = true, indentSize = size, syntax = KotlinCodeSyntax)
+
+    @Test
+    fun `every opening delimiter surrounds the selection and undo restores its direction`() {
+        for ((opening, closing) in EditorPairClosings) {
+            for (backwards in listOf(false, true)) {
+                val state = field("before value after")
+                state.setSelection(if (backwards) 12 else 7, if (backwards) 7 else 12)
+                val originalCarets = state.caretRanges
+                assertTrue(state.typeCharacter(opening))
+                assertEquals("before ${opening}value$closing after", state.text)
+                assertEquals("${opening}value$closing", state.selectedText())
+                assertEquals(backwards, state.caret < state.selectionAnchor!!)
+                assertTrue(state.undo())
+                assertEquals("before value after", state.text)
+                assertEquals(originalCarets, state.caretRanges)
+                assertTrue(state.redo())
+                assertEquals("${opening}value$closing", state.selectedText())
+            }
+        }
+    }
+
+    @Test
+    fun `multiline block wrapping indents all selected lines and retains nested indentation`() {
+        val state = field("one()\nif (ok) {\n    two()\n}")
+        state.selectAll()
+        assertTrue(state.typeCharacter('{'))
+        assertEquals("{\n    one()\n    if (ok) {\n        two()\n    }\n}", state.text)
+        assertEquals(state.text, state.selectedText())
+    }
+
+    @Test
+    fun `multiline quotes preserve selected whitespace`() {
+        val state = field("a\n  b")
+        state.selectAll()
+        state.typeCharacter('"')
+        assertEquals("\"a\n  b\"", state.text)
+    }
+
+    @Test
+    fun `wrapping within an indented block does not double existing indentation or alter literals`() {
+        val state = field("fun run() {\n    one()\n    val raw = \"\"\"\n  literal\n\"\"\"\n}")
+        state.setSelection(state.text.indexOf("one()"), state.text.lastIndexOf('\n'))
+        state.typeCharacter('{')
+        assertEquals("fun run() {\n    {\n        one()\n        val raw = \"\"\"\n  literal\n\"\"\"\n    }\n}", state.text)
+    }
+
+    @Test
+    fun `mixed selections and empty carets each get their own pair behavior`() {
+        val state = field("one two ")
+        state.setCarets(listOf(UiTextCaret(3, 0), UiTextCaret(8)))
+        assertTrue(state.typeCharacter('('))
+        assertEquals("(one) two ()", state.text)
+        assertEquals(listOf(UiTextCaret(5, 0), UiTextCaret(11)), state.caretRanges)
+    }
+
+    @Test
+    fun `backspace removes one indentation step on blank lines and before code`() {
+        for (tail in listOf("", "value")) {
+            val state = field("header\n        $tail", caret = 15)
+            assertTrue(state.backspace())
+            assertEquals("header\n    $tail", state.text)
+            assertEquals(11, state.caret)
+            assertTrue(state.backspace())
+            assertEquals("header\n$tail", state.text)
+        }
+    }
+
+    @Test
+    fun `backspace uses configured visual stops for tabs and partial indentation`() {
+        for ((prefix, remaining) in listOf("      " to "    ", "\t    " to "\t", "  \t" to "", "\t  " to "\t")) {
+            val state = field(prefix + "x", caret = prefix.length)
+            state.backspace()
+            assertEquals(remaining + "x", state.text)
+        }
+        val state = field("      x", caret = 6, size = 2)
+        state.backspace()
+        assertEquals("    x", state.text)
+    }
+
+    @Test
+    fun `backspace keeps ordinary spaces and supports disabling smart indentation`() {
+        for (state in listOf(field("x    "), field("    ", size = null))) {
+            val original = state.text
+            state.backspace()
+            assertEquals(original.dropLast(1), state.text)
+        }
+    }
+
+    @Test
+    fun `backspace handles pairs indentation and ordinary text at separate carets`() {
+        val state = field("()\n        value\nx")
+        state.setCarets(listOf(UiTextCaret(1), UiTextCaret(11), UiTextCaret(state.text.length)))
+        state.backspace()
+        assertEquals("\n    value\n", state.text)
+        assertTrue(state.undo())
+        assertEquals("()\n        value\nx", state.text)
+    }
+
+    @Test
+    fun `enter on a blank line uses the enclosing block rather than the previous chain`() {
+        val state = field("fun run() {\n    Modifier\n        .gap(16.px)\n        ")
+        state.insertNewlineWithIndent()
+        assertTrue(state.text.endsWith("\n        \n    "))
+        state.setText("Modifier\n    .gap(16.px)")
+        state.insertNewlineWithIndent()
+        assertTrue(state.text.endsWith(".gap(16.px)\n"))
+    }
+
+    @Test
+    fun `repeated enter after opening a block never adds another indentation level`() {
+        val state = field("fun run() {")
+        repeat(3) { state.insertNewlineWithIndent() }
+        assertEquals("fun run() {\n    \n    \n    ", state.text)
+    }
+
+    @Test
+    fun `closing a block formats its body and aligns with the matching opener`() {
+        val state = field("fun run() {\none()\nif (ok) {\ntwo()\n}\n    ")
+        val original = state.text
+        state.typeCharacter('}')
+        assertEquals("fun run() {\n    one()\n    if (ok) {\n        two()\n    }\n}", state.text)
+        assertTrue(state.undo())
+        assertEquals(original, state.text)
+        assertTrue(state.redo())
+        assertTrue(state.text.endsWith("\n}"))
+    }
+
+    @Test
+    fun `skipping an existing closing delimiter also corrects indentation`() {
+        for ((opening, closing) in listOf('{' to '}', '(' to ')', '[' to ']')) {
+            val state = field("start$opening\nvalue\n    $closing", caret = 17)
+            state.moveCaret(state.text.lastIndex)
+            assertTrue(state.typeCharacter(closing))
+            assertEquals("start$opening\n    value\n$closing", state.text)
+            assertEquals(state.text.length, state.caret)
+        }
+    }
+
+    @Test
+    fun `closing nested blocks at multiple carets composes indentation and retains every caret`() {
+        val state = field("outer {\ninner {\ncall()\n    }\n    }")
+        val first = state.text.indexOf('}')
+        state.setCarets(listOf(UiTextCaret(first), UiTextCaret(state.text.lastIndex)))
+        assertTrue(state.typeCharacter('}'))
+        assertEquals("outer {\n    inner {\n        call()\n    }\n}", state.text)
+        assertEquals(listOf(state.text.indexOf('}') + 1, state.text.length), state.caretRanges.map { it.position })
+    }
+
+    @Test
+    fun `closing block ignores comment braces and preserves multiline string content`() {
+        val source = "fun run() {\n// }\nval raw = \"\"\"\n  { literal }\n    untouched\n\"\"\"\n    "
+        val state = field(source)
+        state.typeCharacter('}')
+        assertEquals("fun run() {\n    // }\n    val raw = \"\"\"\n  { literal }\n    untouched\n\"\"\"\n}", state.text)
+        val comment = field("//    ")
+        comment.typeCharacter('}')
+        assertEquals("//    }", comment.text)
+    }
+
+    @Test
+    fun `read only fields reject wrapping reindentation and deletion`() {
+        val state = field("value")
+        state.selectAll()
+        state.readOnly = true
+        assertFalse(state.typeCharacter('('))
+        assertFalse(state.backspace())
+        assertFalse(state.insertNewlineWithIndent())
+        assertEquals("value", state.text)
+    }
+
+    @Test
+    fun `closing a block keeps call chains and wrapped expressions one step deeper`() {
+        val chain = field("fun f() {\n    list\n        .map {\n            it\n        }\n")
+        chain.typeCharacter('}')
+        assertEquals("fun f() {\n    list\n        .map {\n            it\n        }\n}", chain.text)
+
+        val wrapped = field("fun f() {\nval x = a +\nb\nlist.map { x ->\nx\n}\n")
+        wrapped.typeCharacter('}')
+        assertEquals("fun f() {\n    val x = a +\n        b\n    list.map { x ->\n        x\n    }\n}", wrapped.text)
+    }
+
+    @Test
+    fun `text that is not Kotlin keeps plain indentation and pairs no apostrophe`() {
+        val story = TextFieldState("# Start\nSmith: What now\n", multiline = true, indentSize = 4)
+        repeat(3) { story.typeCharacter('.') }
+        assertEquals("# Start\nSmith: What now\n...", story.text, "a line of dialogue may start with an ellipsis")
+
+        val prose = TextFieldState("don", multiline = true, indentSize = 4)
+        prose.typeCharacter('\'')
+        prose.typeCharacter('(')
+        assertEquals("don'()", prose.text)
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSmartIndentTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSmartIndentTest.kt
new file mode 100644
index 000000000..b426ecc5e
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldSmartIndentTest.kt
@@ -0,0 +1,164 @@
+package ru.hollowhorizon.hollowengine.client.ui.widgets
+
+import org.junit.jupiter.api.Test
+import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinCodeSyntax
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class TextFieldSmartIndentTest {
+    private fun field(source: String, size: Int? = 4, autoPairs: Boolean = true): TextFieldState {
+        val position = source.indexOf('|')
+        return TextFieldState(source.removeRange(position, position + 1), position,
+            multiline = true, indentSize = size, autoPairs = autoPairs, syntax = KotlinCodeSyntax)
+    }
+
+    @Test
+    fun `backspace joins a normally indented expression to the previous line`() {
+        val state = field("foo {\n    |bar()")
+        assertTrue(state.backspace())
+        assertEquals("foo {bar()", state.text)
+        assertEquals(5, state.caret)
+        assertTrue(state.undo())
+        assertEquals("foo {\n    bar()", state.text)
+        assertEquals(10, state.caret)
+        assertTrue(state.redo())
+        assertEquals("foo {bar()", state.text)
+    }
+
+    @Test
+    fun `backspace removes excess indent before joining at the normal block indent`() {
+        val state = field("foo {\n        |bar()")
+        state.backspace()
+        assertEquals("foo {\n    bar()", state.text)
+        state.backspace()
+        assertEquals("foo {bar()", state.text)
+    }
+
+    @Test
+    fun `backspace removes a blank line including whitespace to the right of the caret`() {
+        for (line in listOf("|", "    |", "  |  ", "\t|")) {
+            val state = field("foo {\n$line\nbar()")
+            state.backspace()
+            assertEquals("foo {\nbar()", state.text)
+            assertEquals(5, state.caret)
+        }
+    }
+
+    @Test
+    fun `backspace keeps the first line and removes excess blank line indentation in steps`() {
+        val first = field("    |")
+        first.backspace()
+        assertEquals("", first.text)
+        val excess = field("foo {\n        |\nbar()")
+        excess.backspace()
+        assertEquals("foo {\n    \nbar()", excess.text)
+        excess.backspace()
+        assertEquals("foo {\nbar()", excess.text)
+    }
+
+    @Test
+    fun `backspace joins at the normal visual indent with a configured tab width`() {
+        for (prefix in listOf("  ", "\t")) {
+            val state = field("foo {\n$prefix|bar()", size = 2)
+            state.backspace()
+            assertEquals("foo {bar()", state.text)
+        }
+    }
+
+    @Test
+    fun `backspace recognizes the ordinary continuation indent before a chain link`() {
+        for (link in listOf(".gap()", "?.gap()")) {
+            val state = field("foo {\n    receiver\n        |$link")
+            state.backspace()
+            assertEquals("foo {\n    receiver$link", state.text)
+        }
+        val state = field("receiver\n            |.gap()")
+        state.backspace()
+        assertEquals("receiver\n        .gap()", state.text)
+    }
+
+    @Test
+    fun `backspace keeps ordinary text and literal indentation behavior`() {
+        val literal = field("val raw = \"\"\"\n    |literal\n\"\"\"")
+        literal.backspace()
+        assertEquals("val raw = \"\"\"\nliteral\n\"\"\"", literal.text)
+        val state = field("foo {\n    bar|()")
+        state.backspace()
+        assertEquals("foo {\n    ba()", state.text)
+    }
+
+    @Test
+    fun `dot indents a continuation and undo restores the original caret`() {
+        val state = field("Modifier\n|")
+        assertTrue(state.typeCharacter('.'))
+        assertEquals("Modifier\n    .", state.text)
+        assertEquals(state.text.length, state.caret)
+        assertTrue(state.undo())
+        assertEquals("Modifier\n", state.text)
+        assertEquals(state.text.length, state.caret)
+    }
+
+    @Test
+    fun `continuations align with the first chain link without accumulating indentation`() {
+        for (previous in listOf("    .gap(16.px)", "    ?.gap(16.px)")) {
+            val state = field("Modifier\n$previous\n|")
+            state.typeCharacter('.')
+            assertEquals("Modifier\n$previous\n    .", state.text)
+        }
+        val state = field("fun run() {\n    Modifier\n    |gap()")
+        state.typeCharacter('.')
+        assertEquals("fun run() {\n    Modifier\n        .gap()", state.text)
+    }
+
+    @Test
+    fun `safe calls and existing manual indentation are preserved`() {
+        val safe = field("receiver\n?|")
+        safe.typeCharacter('.')
+        assertEquals("receiver\n    ?.", safe.text)
+        val manual = field("receiver\n        |")
+        manual.typeCharacter('.')
+        assertEquals("receiver\n        .", manual.text)
+    }
+
+    @Test
+    fun `continuation indentation respects configured width and does not depend on automatic pairs`() {
+        val state = field("receiver\n|", size = 2, autoPairs = false)
+        state.typeCharacter('.')
+        assertEquals("receiver\n  .", state.text)
+    }
+
+    @Test
+    fun `dots inside literals comments and ordinary expressions do not trigger indentation`() {
+        for (source in listOf("// comment\n|", "receiver\n\n|", "foo {\n|", "receiver|", "val raw = \"\"\"\n|\n\"\"\"", "receiver\n// |")) {
+            val state = field(source)
+            val expected = source.replace("|", ".")
+            state.typeCharacter('.')
+            assertEquals(expected, state.text)
+        }
+    }
+
+    @Test
+    fun `continuation works for literal receivers and expressions with trailing comments`() {
+        for (receiver in listOf("\"value\"", "\"value\" // comment", "'x' /* comment */", "\"\"\"value\"\"\" // comment",
+            "receiver // comment", "receiver /* comment */")) {
+            val state = field("$receiver\n|")
+            state.typeCharacter('.')
+            assertEquals("$receiver\n    .", state.text)
+        }
+    }
+
+    @Test
+    fun `multiple carets independently join lines and indent continuations`() {
+        val source = "foo {\n    bar()\n}\nother {\n    baz()"
+        val state = TextFieldState(source, multiline = true, syntax = KotlinCodeSyntax)
+        state.setCarets(listOf(UiTextCaret(source.indexOf("bar()")), UiTextCaret(source.indexOf("baz()"))))
+        state.backspace()
+        assertEquals("foo {bar()\n}\nother {baz()", state.text)
+
+        state.setText("one\n\ntwo\n")
+        state.setCarets(listOf(UiTextCaret(4), UiTextCaret(state.text.length)))
+        state.typeCharacter('.')
+        assertEquals("one\n    .\ntwo\n    .", state.text)
+        assertEquals(listOf(9, state.text.length), state.caretRanges.map { it.position })
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldStateTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldStateTest.kt
index 1a6cbb5ae..12e1fefae 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldStateTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/TextFieldStateTest.kt
@@ -1,6 +1,7 @@
 package ru.hollowhorizon.hollowengine.client.ui.widgets
 
 import org.junit.jupiter.api.Test
+import ru.hollowhorizon.hollowengine.client.ui.ide.KotlinCodeSyntax
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
 import kotlin.test.assertNull
@@ -119,7 +120,7 @@ class TextFieldStateTest {
     fun `enter does not remove literal or comment whitespace near braces`() {
         for (source in listOf("val text = \"{  }\"", "// comment {  }", "val text = \"\"\"{  }\"\"\"")) {
             val position = source.indexOf("{  }") + 2
-            val state = field(source, caret = position)
+            val state = field(source, caret = position).apply { syntax = KotlinCodeSyntax }
             assertTrue(state.insertNewlineWithIndent())
             assertEquals(source.substring(0, position) + "\n" + source.substring(position), state.text)
         }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt
index e3b4fefc9..323aab8f7 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/data/NbtDataStoreTest.kt
@@ -1,5 +1,10 @@
 package ru.hollowhorizon.hollowengine.common.data
 
+import net.minecraft.nbt.CompoundTag
+import net.minecraft.nbt.ListTag
+import net.minecraft.nbt.StringTag
+import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT
+import ru.hollowhorizon.hollowengine.common.utils.nbt.ForTag
 import kotlin.test.*
 
 class NbtDataStoreTest {
@@ -21,6 +26,22 @@ class NbtDataStoreTest {
         assertTrue(visits in restored)
     }
 
+    @Test
+    fun `a key holding a whole tag reads back what was written`() {
+        val compound = DataKey("hollowengine:stories", ForCompoundNBT, null)
+        val list = DataKey("hollowengine:history", ForTag, null)
+        val stories = CompoundTag().apply { put("quest", CompoundTag().apply { putString("stage", "main") }) }
+        val history = ListTag().apply { add(StringTag.valueOf("gate")) }
+        val data = NbtDataStore()
+
+        data[compound] = stories
+        data[list] = history
+
+        val restored = NbtDataStore().apply { load(data.save()) }
+        assertEquals(stories, restored[compound])
+        assertEquals(history, restored[list])
+    }
+
     @Test
     fun `removal restores a non-persistent default`() {
         val data = NbtDataStore()
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DemoStoryTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DemoStoryTest.kt
deleted file mode 100644
index 8ebea56b1..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DemoStoryTest.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
-
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompiler
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryInstruction
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-/**
- * Compiles the demo story that ships in the dev sandbox
- * (`hollowengine/scripts/stories/demo.story`) against the built-in functions.
- *
- * It is the one test that exercises the language and the engine's own function signatures together,
- * so a `@wait`/`@play-sound` whose parameters changed shape cannot quietly break every example.
- */
-class DemoStoryTest {
-    private val source: String = requireNotNull(
-        javaClass.getResourceAsStream("/dialogue/demo.story")?.bufferedReader()?.readText(),
-    ) { "demo.story is missing from the test resources" }
-
-    private val functions = StoryFunctionRegistry().also {
-        StoryBuiltinFunctions.install(it)
-        StoryNpcFunctions.install(it)
-        StoryEffectFunctions.install(it)
-    }
-
-    @Test
-    fun `the demo story compiles without a single diagnostic`() {
-        val result = StoryCompiler.compile("hollowengine:stories/demo.story", source, functions)
-
-        assertEquals(emptyList(), result.diagnostics)
-        assertNotNull(result.program)
-    }
-
-    @Test
-    fun `every jump and call in the demo points at a label that exists`() {
-        val program = assertNotNull(StoryCompiler.compile("test", source, functions).program)
-
-        val targets = program.instructions.mapNotNull { instruction ->
-            when (instruction) {
-                is StoryInstruction.Jump -> instruction.target
-                is StoryInstruction.Call -> instruction.target
-                else -> null
-            }
-        }
-        assertTrue(targets.isNotEmpty())
-        for (target in targets) {
-            if (target.address != null) continue // another file, resolved when it is played
-            assertTrue(target.label in program.labels, "no label '#${target.label}' in the demo")
-        }
-    }
-
-    @Test
-    fun `built-in wait and play-sound accept the calls the demo makes`() {
-        assertNotNull(functions.overloads("wait"))
-        assertEquals(3, functions.overloads("play-sound")?.size, "name, +volume and +pitch forms")
-
-        val calls = """
-            @wait 15s
-            @play-sound minecraft:entity.villager.yes
-            @play-sound minecraft:entity.villager.yes 1.0
-            @play-sound minecraft:block.note_block.bell 1.0 0.8
-        """.trimIndent()
-        val result = StoryCompiler.compile("test", calls, functions)
-
-        assertEquals(emptyList(), result.diagnostics)
-    }
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCheckpointTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCheckpointTest.kt
deleted file mode 100644
index c4f477fb6..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueCheckpointTest.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
-
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import net.minecraft.nbt.CompoundTag
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryAnchor
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-/**
- * Saving and resuming. The rules under test are the ones a player notices: an unchanged story resumes
- * exactly, an edited one rolls back to the last label, and a story that lost the label starts over,
- * with the variables kept in every case.
- */
-class DialogueCheckpointTest {
-    /** Stops the dialogue at a chosen line, the way a server shutdown would. */
-    private class HaltingPresenter(private val haltAfter: Int) : DialoguePresenter {
-        val lines = mutableListOf()
-        private val current = StringBuilder()
-        private var speaker: DialogueCharacter? = null
-        val halted = CompletableDeferred()
-
-        override suspend fun beginLine(session: DialogueSession, speaker: DialogueCharacter?) {
-            current.clear()
-            this.speaker = speaker
-        }
-
-        override suspend fun appendText(session: DialogueSession, text: String) {
-            current.append(text)
-        }
-
-        override suspend fun waitForInput(session: DialogueSession) = Unit
-
-        override suspend fun endLine(session: DialogueSession) {
-            lines += current.toString()
-            current.clear()
-            if (lines.size >= haltAfter) {
-                halted.complete(Unit)
-                CompletableDeferred().await()
-            }
-        }
-
-        override suspend fun showChoices(session: DialogueSession, options: List) = Unit
-        override suspend fun hideChoices(session: DialogueSession, chosen: Int) = Unit
-    }
-
-    private fun controller(
-        sources: Map,
-        presenter: DialoguePresenter,
-        locale: String? = null,
-        configure: StoryFunctionRegistry.() -> Unit = {},
-    ): DialogueController {
-        val functions = StoryFunctionRegistry().apply(configure)
-        val library = StoryLibrary { functions }
-        library.addProvider { address -> sources[address] }
-        return DialogueController("test:main.story", presenter, functions, library, locale = locale)
-    }
-
-    private val story = """
-        Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.
-        # Π‘Π΅Ρ€Π΅Π΄ΠΈΠ½Π°
-        Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°.
-        Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’Ρ€ΠΈ.
-        Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π§Π΅Ρ‚Ρ‹Ρ€Π΅.
-    """.trimIndent()
-
-    /** Plays until [haltAfter] lines have been shown, then saves, the way a shutdown would. */
-    private suspend fun TestScope.playAndSave(
-        sources: Map,
-        haltAfter: Int,
-        setup: DialogueStartScope.() -> Unit = {},
-    ): Pair> {
-        val presenter = HaltingPresenter(haltAfter)
-        val controller = controller(sources, presenter)
-        val job = backgroundScope.launch { controller.startHeadless(setup) }
-        presenter.halted.await()
-        val tag = CompoundTag()
-        controller.save("dialog", tag)
-        job.cancel()
-        return tag to presenter.lines
-    }
-
-    @Test
-    fun `an unchanged story resumes on the statement it was interrupted on`() = runTest {
-        val (tag, before) = playAndSave(mapOf("test:main.story" to story), haltAfter = 3)
-        assertEquals(listOf("Π Π°Π·.", "Π”Π²Π°.", "Π’Ρ€ΠΈ."), before)
-
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val resumed = controller(mapOf("test:main.story" to story), presenter)
-        resumed.load("dialog", tag)
-        val result = resumed.startHeadless()
-
-        assertEquals(DialogueResult.Finished, result)
-        assertEquals(listOf("Π’Ρ€ΠΈ.", "Π§Π΅Ρ‚Ρ‹Ρ€Π΅."), presenter.lines)
-    }
-
-    @Test
-    fun `an edited story rolls back to the last label it was inside`() = runTest {
-        val (tag, _) = playAndSave(mapOf("test:main.story" to story), haltAfter = 3)
-
-        val edited = """
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Вставка ΠΏΠ΅Ρ€Π΅Π΄ ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ.
-            # Π‘Π΅Ρ€Π΅Π΄ΠΈΠ½Π°
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°, пСрСписано.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’Ρ€ΠΈ.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π§Π΅Ρ‚Ρ‹Ρ€Π΅.
-        """.trimIndent()
-
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val resumed = controller(mapOf("test:main.story" to edited), presenter)
-        resumed.load("dialog", tag)
-        resumed.startHeadless()
-
-        assertEquals(listOf("Π”Π²Π°, пСрСписано.", "Π’Ρ€ΠΈ.", "Π§Π΅Ρ‚Ρ‹Ρ€Π΅."), presenter.lines)
-    }
-
-    @Test
-    fun `a story that lost the label starts over but keeps the variables`() = runTest {
-        val (tag, _) = playAndSave(
-            mapOf("test:main.story" to story),
-            haltAfter = 3,
-        ) { put("money", 42) }
-
-        val rewritten = """
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: БовсСм другая история, Π΄Π΅Π½Π΅Π³ {money}.
-        """.trimIndent()
-
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val resumed = controller(mapOf("test:main.story" to rewritten), presenter)
-        resumed.load("dialog", tag)
-        resumed.startHeadless()
-
-        assertEquals(listOf("БовсСм другая история, Π΄Π΅Π½Π΅Π³ 42."), presenter.lines)
-    }
-
-    @Test
-    fun `variables survive a save and keep their type`() = runTest {
-        val checkpoint = StoryCheckpoint(
-            address = "test:main.story",
-            sourceHash = "hash",
-            locale = null,
-            frames = listOf(CheckpointFrame("test:main.story", StoryAnchor("ΠœΠ΅Ρ‚ΠΊΠ°", 3, 7))),
-            variables = mapOf(
-                "money" to StoryNumber(1f),
-                "name" to StoryString("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"),
-                "flag" to StoryBool(true),
-            ),
-        )
-
-        val restored = assertNotNull(StoryCheckpoint.load(checkpoint.save()))
-
-        assertEquals(checkpoint.address, restored.address)
-        assertEquals(checkpoint.frames, restored.frames)
-        assertEquals(StoryNumber(1f), restored.variables["money"])
-        assertEquals(StoryString("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"), restored.variables["name"])
-        assertEquals(StoryBool(true), restored.variables["flag"])
-    }
-
-    @Test
-    fun `finishing clears the checkpoint`() = runTest {
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val controller = controller(mapOf("test:main.story" to story), presenter)
-        controller.startHeadless()
-
-        val tag = CompoundTag()
-        controller.save("dialog", tag)
-
-        assertTrue(tag.isEmpty, "a finished dialogue must not leave a resumable checkpoint")
-    }
-
-    @Test
-    fun `a call stack is restored frame by frame`() = runTest {
-        val nested = """
-            @call #ΠŸΠΎΠ΄ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ°
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ПослС call.
-            @jump #ΠšΠΎΠ½Π΅Ρ†
-            # ΠŸΠΎΠ΄ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ°
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’Π½ΡƒΡ‚Ρ€ΠΈ Ρ€Π°Π·.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’Π½ΡƒΡ‚Ρ€ΠΈ Π΄Π²Π°.
-            @return
-            # ΠšΠΎΠ½Π΅Ρ†
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π€ΠΈΠ½Π°Π».
-        """.trimIndent()
-
-        val (tag, before) = playAndSave(mapOf("test:main.story" to nested), haltAfter = 2)
-        assertEquals(listOf("Π’Π½ΡƒΡ‚Ρ€ΠΈ Ρ€Π°Π·.", "Π’Π½ΡƒΡ‚Ρ€ΠΈ Π΄Π²Π°."), before)
-
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val resumed = controller(mapOf("test:main.story" to nested), presenter)
-        resumed.load("dialog", tag)
-        resumed.startHeadless()
-
-        assertEquals(listOf("Π’Π½ΡƒΡ‚Ρ€ΠΈ Π΄Π²Π°.", "ПослС call.", "Π€ΠΈΠ½Π°Π»."), presenter.lines)
-    }
-
-    @Test
-    fun `a function checkpoint lets a long action continue where it stopped`() = runTest {
-        val source = "@count-to-three\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π“ΠΎΡ‚ΠΎΠ²ΠΎ."
-        var attempts = 0
-        val configure: StoryFunctionRegistry.() -> Unit = {
-            add("count-to-three") {
-                attempts++
-                var counted = state.getInt("counted")
-                while (counted < 3) {
-                    counted++
-                    state.putInt("counted", counted)
-                    if (counted == 2 && attempts == 1) {
-                        throw InterruptedStatement()
-                    }
-                }
-            }
-        }
-
-        val first = HaltingPresenter(Int.MAX_VALUE)
-        val controller = controller(mapOf("test:main.story" to source), first, configure = configure)
-        val failed = controller.startHeadless()
-        assertTrue(failed is DialogueResult.Failed)
-
-        val tag = CompoundTag()
-        controller.save("dialog", tag)
-
-        val second = HaltingPresenter(Int.MAX_VALUE)
-        val resumed = controller(mapOf("test:main.story" to source), second, configure = configure)
-        resumed.load("dialog", tag)
-        resumed.startHeadless()
-
-        assertEquals(listOf("Π“ΠΎΡ‚ΠΎΠ²ΠΎ."), second.lines)
-        assertEquals(2, attempts, "the function is re-entered, not restarted from scratch")
-    }
-
-    @Test
-    fun `a localised story is played and recorded instead of the base one`() = runTest {
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val controller = controller(
-            mapOf(
-                "test:main.story" to "Vitalik: Hello!",
-                "test:main.ru_ru.story" to "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!",
-            ),
-            presenter,
-            locale = "ru_ru",
-        )
-
-        controller.startHeadless()
-
-        assertEquals(listOf("ΠŸΡ€ΠΈΠ²Π΅Ρ‚!"), presenter.lines)
-    }
-
-    @Test
-    fun `a missing translation falls back to the base story`() = runTest {
-        val presenter = HaltingPresenter(Int.MAX_VALUE)
-        val controller = controller(
-            mapOf("test:main.story" to "Vitalik: Hello!"),
-            presenter,
-            locale = "de_de",
-        )
-
-        controller.startHeadless()
-
-        assertEquals(listOf("Hello!"), presenter.lines)
-    }
-
-    @Test
-    fun `localised addresses are built from the file name`() {
-        assertEquals(
-            "project:stories/example.ru_ru.story",
-            StoryLibrary.localizedAddress("project:stories/example.story", "ru_ru"),
-        )
-    }
-
-    private class InterruptedStatement : RuntimeException("interrupted")
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueRuntimeTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueRuntimeTest.kt
deleted file mode 100644
index c891498e0..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/DialogueRuntimeTest.kt
+++ /dev/null
@@ -1,470 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
-
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.withTimeout
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryAnchor
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.actor
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.number
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-import kotlin.time.Duration.Companion.seconds
-
-/** Collects everything a dialogue shows, and answers menus with a scripted list of picks. */
-private open class RecordingPresenter(private val picks: MutableList = mutableListOf()) : DialoguePresenter {
-    val lines = mutableListOf()
-    val menus = mutableListOf>()
-    var started = 0
-    var ended: DialogueResult? = null
-
-    private val current = StringBuilder()
-    private var speaker: DialogueCharacter? = null
-
-    override suspend fun onDialogueStart(session: DialogueSession) {
-        started++
-    }
-
-    override suspend fun onDialogueEnd(session: DialogueSession, result: DialogueResult) {
-        ended = result
-    }
-
-    override suspend fun beginLine(session: DialogueSession, speaker: DialogueCharacter?) {
-        current.clear()
-        this.speaker = speaker
-    }
-
-    override suspend fun appendText(session: DialogueSession, text: String) {
-        current.append(text)
-    }
-
-    override suspend fun waitForInput(session: DialogueSession) {
-        current.append("")
-    }
-
-    override suspend fun endLine(session: DialogueSession) {
-        lines += speaker?.let { "${it.name}: $current" } ?: current.toString()
-        current.clear()
-    }
-
-    override suspend fun showChoices(session: DialogueSession, options: List) {
-        menus += options.map { it.text }
-        val pick = if (picks.isEmpty()) 0 else picks.removeAt(0)
-        (session as DialogueController).forceChoice(pick)
-    }
-
-    /** Every `chosen` the runtime reported when a menu closed, in order. */
-    val decided = mutableListOf()
-
-    override suspend fun hideChoices(session: DialogueSession, chosen: Int) {
-        decided += chosen
-    }
-}
-
-class DialogueRuntimeTest {
-    private fun controller(
-        source: String,
-        presenter: DialoguePresenter,
-        extra: Map = emptyMap(),
-        configure: StoryFunctionRegistry.() -> Unit = {},
-    ): DialogueController {
-        val functions = StoryFunctionRegistry().apply(configure)
-        val sources = mapOf("test:main.story" to source) + extra
-        val library = StoryLibrary { functions }
-        library.addProvider { address -> sources[address] }
-        return DialogueController("test:main.story", presenter, functions, library)
-    }
-
-    @Test
-    fun `lines are shown with speaker, interpolation and inline pauses`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!
-            Π’Π°Ρˆ баланс: {money} ΠΌΠΎΠ½Π΅Ρ‚.[-] Π’ΠΎΡ‡Π½ΠΎ.
-            """.trimIndent(),
-            presenter,
-        )
-
-        val result = controller.startHeadless { put("money", 7) }
-
-        assertEquals(DialogueResult.Finished, result)
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!", "Π’Π°Ρˆ баланс: 7 ΠΌΠΎΠ½Π΅Ρ‚. Π’ΠΎΡ‡Π½ΠΎ."), presenter.lines)
-        assertEquals(1, presenter.started)
-    }
-
-    @Test
-    fun `choices show together and only the picked branch runs`() = runTest {
-        val presenter = RecordingPresenter(mutableListOf(1))
-        val controller = controller(
-            """
-            @choice "БСйчас дСнь."
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ Π΄ΠΎΠ±Ρ€Ρ‹ΠΌ ΡƒΡ‚Ρ€ΠΎΠΌ.
-            @choice "БСйчас Π½ΠΎΡ‡ΡŒ."
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ΠΏΠΎΠΊΠΎΠΉΠ½ΠΎΠΉ Π½ΠΎΡ‡ΠΈ.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Пока.
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless()
-
-        assertEquals(listOf(listOf("БСйчас дСнь.", "БСйчас Π½ΠΎΡ‡ΡŒ.")), presenter.menus)
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ΠΏΠΎΠΊΠΎΠΉΠ½ΠΎΠΉ Π½ΠΎΡ‡ΠΈ.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Пока."), presenter.lines)
-    }
-
-    @Test
-    fun `a choice hidden by its condition is not offered`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            @choice "ΠšΡƒΠΏΠΈΡ‚ΡŒ" if=money>10
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π΅Ρ€ΠΆΠΈ.
-            @choice "Π£ΠΉΡ‚ΠΈ"
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Пока.
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless { put("money", 5) }
-
-        assertEquals(listOf(listOf("Π£ΠΉΡ‚ΠΈ")), presenter.menus)
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Пока."), presenter.lines)
-    }
-
-    @Test
-    fun `choice handlers receive the id`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            @choice "Π’ΠΎΡ€Π³ΠΎΠ²Π°Ρ‚ΡŒ" id=trade
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ΠΌΠΎΡ‚Ρ€ΠΈ Ρ‚ΠΎΠ²Π°Ρ€.
-            """.trimIndent(),
-            presenter,
-        )
-        val seen = mutableListOf()
-        controller.onChoice { seen += it.key }
-
-        controller.startHeadless()
-
-        assertEquals(listOf("trade"), seen)
-    }
-
-    @Test
-    fun `while loop runs until its condition fails`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            @while money > 0
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠžΡΡ‚Π°Π»ΠΎΡΡŒ {money}.
-                @set money = money - 1
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless { put("money", 3) }
-
-        assertEquals(
-            listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠžΡΡ‚Π°Π»ΠΎΡΡŒ 3.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠžΡΡ‚Π°Π»ΠΎΡΡŒ 2.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠžΡΡ‚Π°Π»ΠΎΡΡŒ 1."),
-            presenter.lines,
-        )
-    }
-
-    @Test
-    fun `call returns to the caller while jump does not`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            @call #ΠŸΠΎΠ΄ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ°
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ПослС call.
-            @jump #ΠšΠΎΠ½Π΅Ρ†
-
-            # ΠŸΠΎΠ΄ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ°
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’Π½ΡƒΡ‚Ρ€ΠΈ.
-            @return
-
-            # ΠšΠΎΠ½Π΅Ρ†
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π€ΠΈΠ½Π°Π».
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless()
-
-        assertEquals(
-            listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’Π½ΡƒΡ‚Ρ€ΠΈ.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ПослС call.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π€ΠΈΠ½Π°Π»."),
-            presenter.lines,
-        )
-    }
-
-    @Test
-    fun `jump reaches another story file`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            "@jump test:other.story#Π’ΡƒΡ‚",
-            presenter,
-            extra = mapOf("test:other.story" to "# Π’ΡƒΡ‚\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Другая история."),
-        )
-
-        controller.startHeadless()
-
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Другая история."), presenter.lines)
-    }
-
-    @Test
-    fun `sync hands the track's remainder to the main flow, interrupting a menu`() = runTest {
-        val presenter = object : RecordingPresenter() {
-            override suspend fun showChoices(session: DialogueSession, options: List) {
-                menus += options.map { it.text }
-            }
-        }
-        val controller = controller(
-            """
-            @async name=timer
-                @wait 50ms
-                @sync
-                @jump #Поздно
-            @choice "Π£ΡΠΏΠ΅Ρ‚ΡŒ"
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: УспСл.
-            # Поздно
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: НС успСл.
-            """.trimIndent(),
-            presenter,
-        ) {
-            add("wait", number("time")) { args -> delay(args.millis("time")) }
-        }
-
-        val result = controller.startHeadless()
-
-        assertEquals(DialogueResult.Finished, result)
-        assertEquals(listOf(listOf("Π£ΡΠΏΠ΅Ρ‚ΡŒ")), presenter.menus)
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: НС успСл."), presenter.lines)
-        // Nobody decided anything, so the UI has no winner to celebrate.
-        assertEquals(listOf(-1), presenter.decided)
-    }
-
-    @Test
-    fun `a closing menu is told which option won`() = runTest {
-        // The dialogue UI keeps the winner on screen for a beat before clearing the menu, which it
-        // can only do if closing the menu carries the result rather than just the fact it closed.
-        val presenter = RecordingPresenter(mutableListOf(1, 0))
-        val controller = controller(
-            """
-            @choice "Направо"
-                @jump #Π Π°Π·Π²ΠΈΠ»ΠΊΠ°
-            @choice "НалСво"
-                @jump #Π Π°Π·Π²ΠΈΠ»ΠΊΠ°
-            # Π Π°Π·Π²ΠΈΠ»ΠΊΠ°
-            @choice "Π”Π°Π»ΡŒΡˆΠ΅"
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Пошли.
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless()
-
-        assertEquals(listOf(1, 0), presenter.decided)
-    }
-
-    @Test
-    fun `cancelling a track stops it from preempting`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            @async name=timer
-                @wait 10s
-                @sync
-                @jump #Поздно
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: УспСл.
-            @cancel timer
-            @jump #ΠšΠΎΠ½Π΅Ρ†
-            # Поздно
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: НС успСл.
-            # ΠšΠΎΠ½Π΅Ρ†
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π€ΠΈΠ½Π°Π».
-            """.trimIndent(),
-            presenter,
-        ) {
-            add("wait", number("time")) { args -> delay(args.millis("time")) }
-        }
-
-        controller.startHeadless()
-
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: УспСл.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π€ΠΈΠ½Π°Π»."), presenter.lines)
-    }
-
-    @Test
-    fun `tagged actions report start and end`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller("@wait 1ms tag=timer", presenter) {
-            add("wait", number("time")) { }
-        }
-        val events = mutableListOf()
-        controller.onStart("timer") { events += "start:${it.function}" }
-        controller.onEnd("timer") { events += "end:${it.reason}" }
-
-        controller.startHeadless()
-
-        assertEquals(listOf("start:wait", "end:COMPLETED"), events)
-    }
-
-    @Test
-    fun `an advance that arrives before the wait is not lost`() = runTest {
-        val secondLine = CompletableDeferred()
-        val presenter = object : RecordingPresenter() {
-            override suspend fun endLine(session: DialogueSession) {
-                super.endLine(session)
-                if (lines.size == 2) secondLine.complete(Unit)
-                (session as DialogueController).awaitAdvance()
-            }
-        }
-        val controller = controller("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°.", presenter)
-
-        controller.advance() // before the dialogue even starts
-        backgroundScope.launch { controller.startHeadless() }
-
-        withTimeout(10.seconds) { secondLine.await() }
-
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°."), presenter.lines)
-    }
-
-    @Test
-    fun `an actor argument resolves both on the main flow and inside a track`() = runTest {
-        val presenter = RecordingPresenter()
-        val seen = mutableListOf()
-        val controller = controller(
-            """
-            @greet Π‘Ρ‚ΠΎΡ€ΠΎΠΆ
-            @async name=Ρ„ΠΎΠ½
-                @greet Π‘Ρ‚ΠΎΡ€ΠΎΠΆ
-            @await Ρ„ΠΎΠ½
-            Π‘Ρ‚ΠΎΡ€ΠΎΠΆ: Π“ΠΎΡ‚ΠΎΠ²ΠΎ.
-            """.trimIndent(),
-            presenter,
-        ) {
-            add("greet", actor("who")) { args -> seen += args.actor("who").name }
-        }
-
-        val result = controller.startHeadless {
-            character("Π‘Ρ‚ΠΎΡ€ΠΎΠΆ", DialogueCharacter.of("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"))
-        }
-
-        assertEquals(DialogueResult.Finished, result, "the track must not fail: $result")
-        assertEquals(listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"), seen)
-    }
-
-    @Test
-    fun `a character can be written into text and into a command`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller("МСня Π·ΠΎΠ²ΡƒΡ‚ {Π‘Ρ‚ΠΎΡ€ΠΎΠΆ}, Ρ„Π»Π°Π³ {money}.", presenter)
-
-        controller.startHeadless {
-            character("Π‘Ρ‚ΠΎΡ€ΠΎΠΆ", DialogueCharacter.of("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"))
-            put("money", 7)
-        }
-
-        assertEquals(listOf("МСня Π·ΠΎΠ²ΡƒΡ‚ Π’ΠΈΡ‚Π°Π»ΠΈΠΊ, Ρ„Π»Π°Π³ 7."), presenter.lines)
-    }
-
-    @Test
-    fun `a braced speaker names the character through a value`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            {Π‘Ρ‚ΠΎΡ€ΠΎΠΆ}: Π‘Ρ‚ΠΎΠΉ Π³Π΄Π΅ ΡΡ‚ΠΎΠΈΡˆΡŒ.
-            @set ΠΊΡ‚ΠΎ = "Π‘Ρ‚ΠΎΡ€ΠΎΠΆ"
-            {ΠΊΡ‚ΠΎ}: И Π½Π΅ двигайся.
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless {
-            character("Π‘Ρ‚ΠΎΡ€ΠΎΠΆ", DialogueCharacter.of("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"))
-        }
-
-        assertEquals(
-            listOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘Ρ‚ΠΎΠΉ Π³Π΄Π΅ ΡΡ‚ΠΎΠΈΡˆΡŒ.", "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: И Π½Π΅ двигайся."),
-            presenter.lines,
-        )
-    }
-
-    @Test
-    fun `a braced speaker nothing is bound to fails instead of speaking as itself`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller("{Π‘ΠΌΠΎΡ‚Ρ€ΠΈΡ‚Π΅Π»ΡŒ}: ΠšΡ‚ΠΎ здСсь?", presenter)
-
-        val result = controller.startHeadless()
-
-        assertTrue(result is DialogueResult.Failed, "a misspelled speaker must be reported, not shown")
-    }
-
-    @Test
-    fun `asking a name-only character for a uuid fails clearly`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller("ΠšΡ‚ΠΎ это? {Π‘Ρ‚ΠΎΡ€ΠΎΠΆ.uuid}", presenter)
-
-        val result = controller.startHeadless {
-            character("Π‘Ρ‚ΠΎΡ€ΠΎΠΆ", DialogueCharacter.of("???"))
-        }
-
-        assertTrue(result is DialogueResult.Failed, "a name has no uuid, so this must not print nonsense")
-    }
-
-    @Test
-    fun `lists are ordinary variables`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller(
-            """
-            @set items = ["sword", "shield"]
-            @set items = items + "potion"
-            {items.size} Items: first - {items[0]}, last - {items[2]}.
-            @if items.size > 2
-                Vitalik: That weights...
-            """.trimIndent(),
-            presenter,
-        )
-
-        controller.startHeadless()
-
-        assertEquals(
-            listOf("3 Items: first - sword, last - potion.", "Vitalik: That weights..."),
-            presenter.lines,
-        )
-    }
-
-    @Test
-    fun `a list survives a save and load`() = runTest {
-        val checkpoint = StoryCheckpoint(
-            address = "test:main.story",
-            sourceHash = "hash",
-            locale = null,
-            frames = listOf(CheckpointFrame("test:main.story", StoryAnchor(null, 0, 0))),
-            variables = mapOf(
-                "Π²Π΅Ρ‰ΠΈ" to StoryList(listOf(StoryString("ΠΌΠ΅Ρ‡"), StoryNumber(2f), StoryBool(true))),
-                "Π²Π»ΠΎΠΆΠ΅Π½Π½Ρ‹ΠΉ" to StoryList(listOf(StoryList(listOf(StoryNumber(1f))))),
-            ),
-        )
-
-        val restored = assertNotNull(StoryCheckpoint.load(checkpoint.save()))
-
-        assertEquals(checkpoint.variables["Π²Π΅Ρ‰ΠΈ"], restored.variables["Π²Π΅Ρ‰ΠΈ"])
-        assertEquals(checkpoint.variables["Π²Π»ΠΎΠΆΠ΅Π½Π½Ρ‹ΠΉ"], restored.variables["Π²Π»ΠΎΠΆΠ΅Π½Π½Ρ‹ΠΉ"])
-    }
-
-    @Test
-    fun `metadata parameters reach the handler untouched`() = runTest {
-        val presenter = RecordingPresenter()
-        val controller = controller("@wait 1ms tag=timer overlay=clock", presenter) {
-            add("wait", number("time")) { }
-        }
-        var overlay: String? = null
-        controller.onStart("timer") { overlay = (it.args.metadata["overlay"] as? StoryString)?.value }
-
-        controller.startHeadless()
-
-        assertEquals("clock", overlay)
-    }
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompilerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompilerTest.kt
deleted file mode 100644
index d4886f41a..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompilerTest.kt
+++ /dev/null
@@ -1,229 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
-
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.*
-import kotlin.test.*
-
-class StoryCompilerTest {
-    private val catalog = storyCatalog {
-        "play-video"(string("name"))
-        "play-video"(string("name"), number("volume"))
-        "wait"(number("time"))
-        "camera"(list("location"))
-    }
-
-    private fun compile(source: String) = StoryCompiler.compile("test:story", source, catalog)
-
-    @Test
-    fun `adjacent choices become one menu that continues past the whole group`() {
-        val result = compile(
-            """
-            @choice "Π”Π΅Π½ΡŒ"
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π£Ρ‚Ρ€ΠΎ.
-            @choice "ΠΠΎΡ‡ΡŒ"
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ΠΏΠΎΠΊΠΎΠΉΠ½ΠΎΠΉ Π½ΠΎΡ‡ΠΈ.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Пока.
-            """.trimIndent(),
-        )
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-
-        val menu = assertIs(program.instructions[0])
-        assertEquals(2, menu.options.size)
-        val exitInstruction = program.instructions[menu.exit]
-        assertIs(exitInstruction)
-        assertEquals("Пока.", exitInstruction.text.literalText())
-
-        for (option in menu.options) {
-            val jump = program.instructions.drop(option.bodyStart).filterIsInstance().first()
-            assertEquals(menu.exit, jump.target)
-        }
-    }
-
-    @Test
-    fun `conditional choices keep their condition and id`() {
-        val result = compile(
-            """
-            @choice "ΠšΡƒΠΏΠΈΡ‚ΡŒ" if=money>10 id=buy
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π΅Ρ€ΠΆΠΈ.
-            """.trimIndent(),
-        )
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-        val menu = assertIs(program.instructions[0])
-        assertEquals("buy", menu.options[0].id)
-        assertNotNull(menu.options[0].condition)
-    }
-
-    @Test
-    fun `while loops back to its condition`() {
-        val result = compile(
-            """
-            @while money > 10
-                @set money = money - 1
-            """.trimIndent(),
-        )
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-
-        val exit = assertIs(program.instructions[0])
-        val loop = assertIs(program.instructions[2])
-        assertEquals(0, loop.target, "loop must return to the condition")
-        assertEquals(3, exit.target, "false must leave the loop")
-    }
-
-    @Test
-    fun `if else-if else picks exactly one branch`() {
-        val result = compile(
-            """
-            @if money > 10
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ΠΎΠ³Π°Ρ‚.
-            @else-if money > 0
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π•ΡΡ‚ΡŒ Π½Π΅ΠΌΠ½ΠΎΠ³ΠΎ.
-            @else
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡƒΡΡ‚ΠΎ.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠšΠΎΠ½Π΅Ρ†.
-            """.trimIndent(),
-        )
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-        val says = program.instructions.filterIsInstance()
-        assertEquals(4, says.size)
-
-        val last = program.instructions.indexOfLast { it is StoryInstruction.Say }
-        val gotos = program.instructions.filterIsInstance()
-        assertTrue(gotos.count { it.target == last } >= 2)
-    }
-
-    @Test
-    fun `async body sits inline and the main flow skips it`() {
-        val result = compile(
-            """
-            @async name=cam
-                @camera location=[10, 45, 5]
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π°Π»ΡŒΡˆΠ΅.
-            """.trimIndent(),
-        )
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-
-        val start = assertIs(program.instructions[0])
-        assertEquals("cam", start.trackName)
-        assertEquals(1, start.bodyStart)
-        assertEquals(2, start.bodyEnd)
-        assertIs(program.instructions[1])
-        assertIs(program.instructions[start.bodyEnd])
-    }
-
-    @Test
-    fun `inline async compiles to the same shape as a block`() {
-        val result = compile("@async camera location=[10, 45, 5]")
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-        val start = assertIs(program.instructions[0])
-        assertEquals(1, start.bodyStart)
-        assertEquals(2, start.bodyEnd)
-    }
-
-    @Test
-    fun `anchors are label plus offset so a save survives edits above`() {
-        val result = compile(
-            """
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΎΠ»ΠΎΠ³.
-            # Начало
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°.
-            """.trimIndent(),
-        )
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-        val two = program.instructions.indexOfFirst { it is StoryInstruction.Say && it.text.literalText() == "Π”Π²Π°." }
-
-        val anchor = program.anchorOf(two)
-        assertEquals("Начало", anchor.label)
-        assertEquals(1, anchor.offset)
-
-        val edited = compile(
-            """
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΎΠ»ΠΎΠ³.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π•Ρ‰Ρ‘ строка.
-            # Начало
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°.
-            """.trimIndent(),
-        ).program!!
-        val resolved = assertNotNull(edited.resolve(anchor))
-        val instruction = assertIs(edited.instructions[resolved])
-        assertEquals("Π”Π²Π°.", instruction.text.literalText())
-    }
-
-    @Test
-    fun `unknown command warns but still compiles`() {
-        val result = compile("@teleport-everyone")
-
-        assertNotNull(result.program)
-        assertTrue(result.diagnostics.any { it.severity == StorySeverity.WARNING && "Unknown command" in it.message })
-        assertTrue(result.diagnostics.none { it.severity == StorySeverity.ERROR })
-    }
-
-    @Test
-    fun `overloads are matched by arity and literal types`() {
-        assertNotNull(compile("@play-video example.ogg").program)
-        assertNotNull(compile("@play-video example.ogg 1.0").program)
-
-        val tooMany = compile("@play-video example.ogg 1.0 extra")
-        assertNull(tooMany.program)
-        assertTrue(tooMany.diagnostics.any { "No overload" in it.message })
-
-        val wrongType = compile("@wait notanumber")
-        assertNull(wrongType.program)
-    }
-
-    @Test
-    fun `undeclared named parameters become metadata instead of errors`() {
-        val result = compile("@play-video example.ogg tag=intro subtitle=hello")
-        val program = assertNotNull(result.program, result.diagnostics.toString())
-        val invoke = assertIs(program.instructions[0])
-
-        assertEquals("intro", invoke.call.tag)
-        assertTrue("subtitle" in invoke.call.metadata)
-        assertEquals(1, invoke.call.args.size, "only the declared parameter stays an argument")
-    }
-
-    @Test
-    fun `dialogue lines inside async are rejected before sync`() {
-        val result = compile(
-            """
-            @async
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: НСльзя.
-            """.trimIndent(),
-        )
-        assertNull(result.program)
-        assertTrue(result.diagnostics.any { "before '@sync'" in it.message })
-    }
-
-    @Test
-    fun `after sync a track may speak, because it is the main flow by then`() {
-        val result = compile(
-            """
-            @async name=timer
-                @wait 1s
-                @sync
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ВрСмя Π²Ρ‹ΡˆΠ»ΠΎ.
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: УспСл.
-            """.trimIndent(),
-        )
-        assertNotNull(result.program, result.diagnostics.toString())
-    }
-
-    @Test
-    fun `indenting under a line that opens nothing is an error`() {
-        val result = compile(
-            """
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚.
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠžΡ‚ΡΡ‚ΡƒΠΏ.
-            """.trimIndent(),
-        )
-        assertNull(result.program)
-        assertTrue(result.diagnostics.any { "Unexpected indentation" in it.message })
-    }
-
-    @Test
-    fun `duplicate labels are rejected`() {
-        val result = compile("# Начало\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.\n# Начало\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°.")
-        assertNull(result.program)
-        assertTrue(result.diagnostics.any { "Duplicate label" in it.message })
-    }
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompletionContextTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompletionContextTest.kt
deleted file mode 100644
index ff05b0564..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryCompletionContextTest.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
-
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryCompletionContext
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.storyCompletionContext
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertIs
-
-/**
- * The caret context is read by scanning, not by matching patterns, and it has to hold up on lines
- * that do not parse yet, an unclosed `[` or a half-typed name is exactly when completion is asked
- * for.
- */
-class StoryCompletionContextTest {
-    private fun contextOf(line: String) = storyCompletionContext(line, line.length)
-
-    @Test
-    fun `a line that is not a command offers nothing`() {
-        assertIs(contextOf("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚"))
-        assertIs(contextOf(""))
-        assertIs(contextOf("# ΠœΠ΅Ρ‚ΠΊΠ°"))
-    }
-
-    @Test
-    fun `the command name is recognised while it is typed`() {
-        assertEquals(StoryCompletionContext.Command("ch"), contextOf("@ch"))
-        assertEquals(StoryCompletionContext.Command(""), contextOf("    @"))
-        assertEquals(StoryCompletionContext.Command("play-so"), contextOf("@play-so"))
-    }
-
-    @Test
-    fun `jump and call name labels`() {
-        assertEquals(StoryCompletionContext.Label("Нач"), contextOf("@jump #Нач"))
-        assertEquals(StoryCompletionContext.Label(""), contextOf("@call "))
-    }
-
-    @Test
-    fun `a named parameter value is found even with the list still open`() {
-        val open = contextOf("@hide-hud except=[chat, sub")
-        assertIs(open)
-        assertEquals("except", open.parameter)
-        assertEquals("sub", open.typed)
-
-        val plain = contextOf("@hide-hud except=ch")
-        assertIs(plain)
-        assertEquals("except", plain.parameter)
-        assertEquals("ch", plain.typed)
-    }
-
-    @Test
-    fun `an unnamed argument knows which position it is`() {
-        val first = contextOf("@walk-to Π’ΠΈΡ‚")
-        assertIs(first)
-        assertEquals(null, first.parameter)
-        assertEquals(0, first.positional)
-        assertEquals("Π’ΠΈΡ‚", first.typed)
-
-        val third = contextOf("""@play-video "ΠΊΠ»ΠΈΠΏ.ogg" [1, 2] x""")
-        assertIs(third)
-        assertEquals(2, third.positional)
-        assertEquals("x", third.typed)
-    }
-
-    @Test
-    fun `an open brace is an expression, wherever it sits`() {
-        val inArgument = contextOf("@wait {mo")
-        assertIs(inArgument)
-        assertEquals("mo", inArgument.typed)
-
-        val inText = contextOf("Баланс {mo")
-        assertIs(inText)
-        assertEquals("mo", inText.typed)
-
-        assertIs(contextOf("@wait {money} ti"))
-        assertIs(contextOf("Баланс {money} ΠΌΠΎΠ½Π΅Ρ‚"))
-    }
-
-    @Test
-    fun `a quoted argument does not swallow the rest of the line`() {
-        val context = contextOf("""@choice "ΠšΡƒΠΏΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‡" id""")
-        assertIs(context)
-        assertEquals("id", context.typed)
-        assertEquals(1, context.positional)
-    }
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryParserTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryParserTest.kt
deleted file mode 100644
index 5a978b5c9..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/StoryParserTest.kt
+++ /dev/null
@@ -1,214 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
-
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryExpression
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryLineKind
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.StoryParser
-import ru.hollowhorizon.hollowengine.common.dialogue.lang.TextPart
-import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType
-import ru.hollowhorizon.hollowengine.common.scripting.ide.story.StoryScriptingAnalyzer
-import ru.hollowhorizon.hollowengine.common.utils.expressions.Ast
-import kotlin.test.*
-
-class StoryParserTest {
-    @Test
-    fun `printing an unchanged tree reproduces the source byte for byte`() {
-        val source = """
-            # Начало   // ΠΌΠ΅Ρ‚ΠΊΠ°
-
-            @play-background project:sounds/bg/music_1.ogg
-            	Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!
-            Π’Π°Ρˆ баланс: {money} ΠΌΠΎΠ½Π΅Ρ‚.
-
-            @choice "БСйчас дСнь."   // с ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅ΠΌ
-                Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ Π΄ΠΎΠ±Ρ€Ρ‹ΠΌ ΡƒΡ‚Ρ€ΠΎΠΌ.
-        """.trimIndent() + "\r\nхвост Π±Π΅Π· ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄Π° строки"
-
-        assertEquals(source, StoryParser.parse(source).cst.print())
-    }
-
-    @Test
-    fun `speaker is only recognised for a single word before the colon`() {
-        val parsed = StoryParser.parse("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!\nΠ’Π°Ρˆ баланс: 10 ΠΌΠΎΠ½Π΅Ρ‚.")
-        val first = parsed.cst.lines[0].kind
-        assertIs(first)
-        assertEquals("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ", first.speaker)
-        assertEquals("ΠŸΡ€ΠΈΠ²Π΅Ρ‚!", first.text.literalText())
-
-        val second = parsed.cst.lines[1].kind
-        assertIs(second)
-        assertNull(second.speaker)
-        assertEquals("Π’Π°Ρˆ баланс: 10 ΠΌΠΎΠ½Π΅Ρ‚.", second.text.literalText())
-    }
-
-    @Test
-    fun `a braced expression right before the colon is the speaker`() {
-        val parsed = StoryParser.parse("{player}: Π‘Ρ‚ΠΎΡŽ!\n{money} ΠΌΠΎΠ½Π΅Ρ‚: Π½Π΅ густо.")
-        val braced = parsed.cst.lines[0].kind
-        assertIs(braced)
-        assertNull(braced.speaker)
-        assertEquals("player", (braced.speakerExpr!!.ast as Ast.Name).name)
-        assertEquals("Π‘Ρ‚ΠΎΡŽ!", braced.text.literalText())
-
-        // The braces have to sit on the colon; anything between them makes it an ordinary line.
-        val narration = parsed.cst.lines[1].kind
-        assertIs(narration)
-        assertNull(narration.speakerExpr)
-        assertNull(narration.speaker)
-        assertEquals(" ΠΌΠΎΠ½Π΅Ρ‚: Π½Π΅ густо.", narration.text.literalText())
-    }
-
-    @Test
-    fun `a single word before a colon is always a speaker`() {
-        val parsed = StoryParser.parse("Баланс: 10 ΠΌΠΎΠ½Π΅Ρ‚.\nБаланс\\: 10 ΠΌΠΎΠ½Π΅Ρ‚.")
-        val speakerLine = parsed.cst.lines[0].kind
-        assertIs(speakerLine)
-        assertEquals("Баланс", speakerLine.speaker)
-
-        val escaped = parsed.cst.lines[1].kind
-        assertIs(escaped)
-        assertNull(escaped.speaker)
-        assertEquals("Баланс: 10 ΠΌΠΎΠ½Π΅Ρ‚.", escaped.text.literalText())
-    }
-
-    @Test
-    fun `text splits into interpolation, inline calls and wait markers`() {
-        val parsed = StoryParser.parse("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Баланс {money} ΠΌΠΎΠ½Π΅Ρ‚.[-] Π–Π΄Ρ‘ΠΌ [wait 1s] ΠΈ дальшС.")
-        val kind = parsed.cst.lines[0].kind
-        assertIs(kind)
-        assertEquals("Π’ΠΈΡ‚Π°Π»ΠΈΠΊ", kind.speaker)
-
-        val kinds = kind.text.parts.map { it::class.simpleName }
-        assertEquals(
-            listOf("Literal", "Interpolation", "Literal", "WaitInput", "Literal", "InlineCall", "Literal"),
-            kinds,
-        )
-        val call = kind.text.parts.filterIsInstance().single().call
-        assertEquals("wait", call.function)
-        assertEquals(1, call.args.size)
-    }
-
-    @Test
-    fun `duration literals become milliseconds`() {
-        val parsed = StoryParser.parse("@fade-in 1sec #000000\n@wait 500ms\n@wait 2min")
-        fun firstArg(index: Int): Float {
-            val kind = parsed.cst.lines[index].kind
-            assertIs(kind)
-            return (controllerless(kind.args[0].expr) as StoryNumber).value
-        }
-        assertEquals(1000f, firstArg(0))
-        assertEquals(500f, firstArg(1))
-        assertEquals(120_000f, firstArg(2))
-    }
-
-    @Test
-    fun `a list argument holds values, not variable names`() {
-        val parsed = StoryParser.parse("@hide-hud except=[chat, subtitle_overlay] only=[10, 2.5]")
-        val kind = parsed.cst.lines[0].kind
-        assertIs(kind)
-
-        val except = (kind.args.first { it.name == "except" }.expr).parts
-        assertEquals(
-            listOf("chat", "subtitle_overlay"),
-            except.map { (it.constant as StoryString).value },
-        )
-
-        val numbers = kind.args.first { it.name == "only" }.expr.parts
-        assertEquals(
-            listOf(10f, 2.5f),
-            numbers.map { (it.constant as StoryNumber).value },
-        )
-    }
-
-    @Test
-    fun `negative numbers are numbers, not strings`() {
-        val parsed = StoryParser.parse("@camera location=[25, 70, -171] rotation=[-158, 2] fov=-1.5")
-        val kind = parsed.cst.lines[0].kind
-        assertIs(kind)
-
-        fun numbers(argument: String) = kind.args.first { it.name == argument }.expr
-            .parts.map { (it.evaluate { null } as StoryNumber).value }
-
-        assertEquals(listOf(25f, 70f, -171f), numbers("location"))
-        assertEquals(listOf(-158f, 2f), numbers("rotation"))
-        assertEquals(-1.5f, (kind.args.first { it.name == "fov" }.expr.evaluate { null } as StoryNumber).value)
-    }
-
-    @Test
-    fun `a negative number is highlighted whole, sign included`() {
-        val source = "@camera rotation=[-158, 2]"
-        val spans = StoryScriptingAnalyzer.highlight("example.story", source, 0)
-            .flatMap { it.spans }
-
-        val sign = spans.first { it.first == "-158" }
-        assertEquals(TokenType.NUMERIC_LITERAL, sign.second.color)
-    }
-
-    @Test
-    fun `a list argument still takes expressions in braces`() {
-        val parsed = StoryParser.parse("@hide-hud except=[{Π²Ρ‹Π±Ρ€Π°Π½Π½Ρ‹ΠΉ}, chat]")
-        val kind = parsed.cst.lines[0].kind
-        assertIs(kind)
-
-        val items = kind.args.single().expr.parts
-        assertIs(items[0].ast)
-        assertNotNull(items[1].constant)
-    }
-
-    @Test
-    fun `a condition is an expression, spaces and all when quoted`() {
-        val tight = StoryParser.parse("@choice \"Π’Π·ΡΡ‚ΡŒ\" if=Π²Π΅Ρ‰ΠΈ.size>0").cst.lines[0].kind
-        assertIs(tight)
-        assertIs(tight.args.single().expr.ast)
-
-        val quoted = StoryParser.parse("@choice \"Π’Π·ΡΡ‚ΡŒ\" if=\"Π²Π΅Ρ‰ΠΈ.size > 0\"").cst.lines[0].kind
-        assertIs(quoted)
-        assertIs(quoted.args.single().expr.ast)
-    }
-
-    @Test
-    fun `a comment inside quotes is not a comment`() {
-        val parsed = StoryParser.parse("""@choice "Π‘ΠΊΠΈΠ΄ΠΊΠ° 50// ΠΏΡ€ΠΎΡ†Π΅Π½Ρ‚ΠΎΠ²" id=sale""")
-        val kind = parsed.cst.lines[0].kind
-        assertIs(kind)
-        assertEquals("Π‘ΠΊΠΈΠ΄ΠΊΠ° 50// ΠΏΡ€ΠΎΡ†Π΅Π½Ρ‚ΠΎΠ²", kind.text.literalText())
-        assertNull(parsed.cst.lines[0].comment)
-    }
-
-    @Test
-    fun `vanilla command keeps its selector brackets`() {
-        val parsed = StoryParser.parse("@command gamemode @p creative")
-        val kind = parsed.cst.lines[0].kind
-        assertIs(kind)
-        assertEquals("gamemode @p creative", kind.text.literalText())
-
-        val selector = StoryParser.parse("@command tp @e[tag=npc] ~ ~ ~").cst.lines[0].kind
-        assertIs(selector)
-        assertEquals("tp @e[tag=npc] ~ ~ ~", selector.text.literalText())
-    }
-
-    @Test
-    fun `jump targets split into address and label`() {
-        val parsed = StoryParser.parse("@jump project:stories/other.story#Начало\n@jump #Π›ΠΎΠΊΠ°Π»ΡŒΠ½ΠΎ")
-        val global = parsed.cst.lines[0].kind
-        assertIs(global)
-        assertEquals("project:stories/other.story", global.target.address)
-        assertEquals("Начало", global.target.label)
-
-        val local = parsed.cst.lines[1].kind
-        assertIs(local)
-        assertNull(local.target.address)
-        assertEquals("Π›ΠΎΠΊΠ°Π»ΡŒΠ½ΠΎ", local.target.label)
-    }
-
-    @Test
-    fun `a broken line becomes a diagnostic without losing the text`() {
-        val source = "# \n@jump\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!"
-        val parsed = StoryParser.parse(source)
-
-        assertTrue(parsed.diagnostics.hasErrors)
-        assertEquals(source, parsed.cst.print())
-        assertIs(parsed.cst.lines[2].kind)
-    }
-
-    private fun controllerless(expr: StoryExpression): StoryValue = expr.evaluate { null }
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/models/AnimatorEditsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/models/AnimatorEditsTest.kt
new file mode 100644
index 000000000..d0618f31a
--- /dev/null
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/models/AnimatorEditsTest.kt
@@ -0,0 +1,47 @@
+package ru.hollowhorizon.hollowengine.common.models
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class AnimatorEditsTest {
+    private val animator = Animator(
+        layers = listOf(
+            AnimationControllerLayerSpec(
+                id = "layer",
+                states = listOf("a", "b", "c").map { ClipStateSpec(id = it, animation = it) },
+                transitions = listOf(
+                    AnimationControllerTransitionSpec(from = "a", to = "b"),
+                    AnimationControllerTransitionSpec(from = "b", to = "c"),
+                ),
+            )
+        )
+    )
+
+    @Test
+    fun `moving one state keeps the default places of the others`() {
+        val before = animator.nodeLayout("layer")
+
+        val moved = animator.withNodeAt("layer", "b", GraphPoint(700f, 500f)).nodeLayout("layer")
+
+        assertEquals(before - "b", moved - "b")
+    }
+
+    @Test
+    fun `removing a state keeps the default places of the others`() {
+        val before = animator.nodeLayout("layer")
+
+        val left = animator.withoutState("layer", "a").nodeLayout("layer")
+
+        assertEquals(before - "a", left)
+    }
+
+    @Test
+    fun `a transition is found and removed by its states, wherever it is in the list`() {
+        val shifted = animator.withoutTransition("layer", "a", "b")
+
+        val controller = checkNotNull(shifted.controller("layer"))
+        assertNull(controller.transition("a", "b"))
+        assertEquals(listOf("b" to "c"), controller.transitions.map { it.from to it.to })
+    }
+}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzerTest.kt
deleted file mode 100644
index 774ec2682..000000000
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/story/StoryScriptingAnalyzerTest.kt
+++ /dev/null
@@ -1,271 +0,0 @@
-package ru.hollowhorizon.hollowengine.common.scripting.ide.story
-
-import ru.hollowhorizon.hollowengine.common.scripting.ide.CompletionItem
-import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity
-import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType
-import ru.hollowhorizon.hollowengine.common.scripting.ide.collectCompletions
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-class StoryScriptingAnalyzerTest {
-    private fun styleOf(text: String, fragment: String): TokenType {
-        val lines = StoryScriptingAnalyzer.highlight("example.story", text, 0)
-        val piece = lines.flatMap { it.spans }.firstOrNull { it.first == fragment }
-        assertNotNull(piece, "no span rendered exactly as '$fragment'")
-        return piece.second.color
-    }
-
-    @Test
-    fun `highlighting tells speakers, commands and comments apart`() {
-        val source = """
-            # Начало // ΠΌΠ΅Ρ‚ΠΊΠ°
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!
-            @wait 1s
-            @jump #Начало
-        """.trimIndent()
-
-        assertEquals(TokenType.TOP_LEVEL, styleOf(source, "# Начало"))
-        assertEquals(TokenType.COMMENT, styleOf(source, "// ΠΌΠ΅Ρ‚ΠΊΠ°"))
-        assertEquals(TokenType.CLASS, styleOf(source, "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ"))
-        assertEquals(TokenType.FUNCTION, styleOf(source, "@wait"))
-        assertEquals(TokenType.KEYWORD, styleOf(source, "@jump"))
-        assertEquals(TokenType.STRING, styleOf(source, "#Начало"))
-    }
-
-    @Test
-    fun `interpolation and named arguments are colored inside a line`() {
-        val source = "Баланс {money} ΠΌΠΎΠ½Π΅Ρ‚.\n@play-video example.ogg volume=1.0"
-
-        assertEquals(TokenType.VARIABLE, styleOf(source, "{money}"))
-        assertEquals(TokenType.VALUE_ARGUMENT_NAME, styleOf(source, "volume"))
-        assertEquals(TokenType.NUMERIC_LITERAL, styleOf(source, "1.0"))
-    }
-
-    @Test
-    fun `highlighting covers the source exactly once`() {
-        val source = """
-            # Начало
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Баланс {money}.[-]
-            @choice "ΠšΡƒΠΏΠΈΡ‚ΡŒ" if=money>10 id=buy
-                @set money = money - 10
-        """.trimIndent()
-
-        val rendered = StoryScriptingAnalyzer.highlight("example.story", source, 0)
-            .joinToString("\n") { line -> line.spans.joinToString("") { it.first } }
-
-        assertEquals(source, rendered)
-    }
-
-    @Test
-    fun `diagnostics come from the compiler`() {
-        val diagnostics = StoryScriptingAnalyzer.diagnostic(
-            "example.story",
-            "# Начало\n# Начало\n@jump #НСт",
-        )
-
-        assertTrue(diagnostics.any { it.severity == Severity.ERROR && "Duplicate label" in it.message })
-        val duplicate = diagnostics.first { "Duplicate label" in it.message }
-        assertEquals(1, duplicate.range.start.line)
-    }
-
-    @Test
-    fun `a clean story reports nothing`() {
-        val source = """
-            # Начало
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚!
-            @choice "Π”Π°Π»ΡŒΡˆΠ΅"
-                @jump #Начало
-        """.trimIndent()
-
-        assertEquals(emptyList(), StoryScriptingAnalyzer.diagnostic("example.story", source))
-    }
-
-    @Test
-    fun `formatting tags are highlighted without swallowing their text`() {
-        val source = "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠžΡ‡Π΅Π½ΡŒ Π³Ρ€ΠΎΠΌΠΊΠΎ!"
-
-        assertEquals(TokenType.ANNOTATION, styleOf(source, ""))
-        assertEquals(TokenType.ANNOTATION, styleOf(source, ""))
-        assertEquals(TokenType.DEFAULT, styleOf(source, "ΠžΡ‡Π΅Π½ΡŒ "))
-    }
-
-    @Test
-    fun `formatted dialogue tags are validated in lines and choices`() {
-        val valid = "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ΠΌΠ΅Π»ΠΎ ΠΈ ярко.\n" +
-                "@choice \"ΠžΡ‚Π²Π΅Ρ‚\"\n    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π₯ΠΎΡ€ΠΎΡˆΠΎ."
-        assertEquals(emptyList(), StoryScriptingAnalyzer.diagnostic("example.story", valid))
-
-        val invalid = StoryScriptingAnalyzer.diagnostic(
-            "example.story",
-            "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Ошибка",
-        )
-        assertTrue(invalid.any { it.severity == Severity.WARNING && "finite number" in it.message })
-    }
-
-    @Test
-    fun `commands complete after an at sign`() {
-        val source = "@ch"
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertTrue(items.any { it.show == "@choice" })
-        assertTrue(items.none { it.show == "@jump" })
-    }
-
-    @Test
-    fun `jump completes the labels of the file`() {
-        val source = "# Начало\n# Ворговля\n@jump #"
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertEquals(listOf("#Начало", "#Ворговля"), items.map { it.show })
-    }
-
-    @Test
-    fun `interpolation completes variables the story uses`() {
-        val source = "@set money = 0\nБаланс {mo"
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertEquals(listOf("money"), items.map { it.show })
-    }
-
-    @Test
-    fun `a label highlights together with every jump and call that reaches it`() {
-        val source = """
-            @jump #ΠšΠΎΠ½Π΅Ρ†
-            @call #ΠšΠΎΠ½Π΅Ρ†
-            @jump other.story#ΠšΠΎΠ½Π΅Ρ†
-            # ΠšΠΎΠ½Π΅Ρ†
-            Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Всё.
-        """.trimIndent()
-
-        val fromLabel = StoryScriptingAnalyzer.occurrences("example.story", source, source.indexOf("# ΠšΠΎΠ½Π΅Ρ†") + 3)
-        val fromJump = StoryScriptingAnalyzer.occurrences("example.story", source, source.indexOf("#ΠšΠΎΠ½Π΅Ρ†") + 2)
-
-        assertEquals(fromLabel, fromJump)
-        assertEquals(3, fromLabel.size, "the label plus the two local references")
-        assertTrue(fromLabel.none { it.start > source.indexOf("other.story") && it.start < source.indexOf("# ΠšΠΎΠ½Π΅Ρ†") })
-        fromLabel.forEach { assertEquals("ΠšΠΎΠ½Π΅Ρ†", source.substring(it.start, it.end)) }
-    }
-
-    @Test
-    fun `a variable highlights everywhere it is written or read`() {
-        val source = """
-            @set money = 10
-            @if money > 5
-                Баланс {money} ΠΌΠΎΠ½Π΅Ρ‚.
-            @choice "ΠšΡƒΠΏΠΈΡ‚ΡŒ" if=money>10
-                @set other = money
-        """.trimIndent()
-
-        val ranges = StoryScriptingAnalyzer.occurrences("example.story", source, source.indexOf("money") + 2)
-
-        assertEquals(5, ranges.size, ranges.map { source.substring(it.start, it.end) }.toString())
-        ranges.forEach { assertEquals("money", source.substring(it.start, it.end)) }
-    }
-
-    @Test
-    fun `an unknown command is a warning, not an error`() {
-        val diagnostics = StoryScriptingAnalyzer.diagnostic("example.story", "@teleport-everyone\n[unknown-inline]")
-
-        assertTrue(diagnostics.isNotEmpty())
-        assertTrue(
-            diagnostics.none { it.severity == Severity.ERROR },
-            "a function may still be registered on the controller: $diagnostics",
-        )
-        assertTrue(diagnostics.all { it.severity == Severity.WARNING })
-    }
-
-    @Test
-    fun `built-in and unknown commands are drawn differently`() {
-        val source = "@jump #Π’ΡƒΡ‚\n@play-video example.ogg\n# Π’ΡƒΡ‚"
-        val lines = StoryScriptingAnalyzer.highlight("example.story", source, 0)
-        val spans = lines.flatMap { it.spans }
-
-        val builtin = spans.first { it.first == "@jump" }.second
-        val unknown = spans.first { it.first == "@play-video" }.second
-
-        assertEquals(TokenType.KEYWORD, builtin.color)
-        assertEquals(TokenType.FUNCTION, unknown.color)
-        assertTrue(unknown.italic, "a command nothing has registered is set apart by italics")
-    }
-
-    @Test
-    fun `command completion replaces the at sign instead of doubling it`() {
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", "@ch", 3)
-        val choice = items.first { it.show == "@choice" }
-
-        assertTrue('@' in choice.wordChars, "the item must claim the '@' it is typed over")
-        assertTrue('-' in choice.wordChars, "hyphenated names must replace the part already typed")
-    }
-
-    @Test
-    fun `each overload is its own entry, with its parameters and their defaults`() {
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", "@", 1)
-
-        val sounds = items.filter { it.show == "@play-sound" }.map { (it as CompletionItem.Declaration).middle }
-        assertEquals(3, sounds.size, "one entry per overload: $sounds")
-        assertTrue(sounds.all { it.orEmpty().startsWith(" ") }, sounds.toString())
-        assertTrue(sounds.any { it == " name volume pitch" }, sounds.toString())
-
-        val wait = items.first { it.show == "@wait" } as CompletionItem.Declaration
-        assertEquals(" time", wait.middle)
-
-        val jump = items.first { it.show == "@jump" } as CompletionItem.Declaration
-        assertEquals(" #label | file.story#label", jump.middle)
-    }
-
-    @Test
-    fun `an actor argument offers the characters the file speaks to`() {
-        val source = "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΈΠ²Π΅Ρ‚.\nΠ‘Ρ‚ΠΎΡ€ΠΎΠΆ: Π‘Ρ‚ΠΎΠΉ.\n@walk-to "
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertTrue(items.any { it.show == "Π’ΠΈΡ‚Π°Π»ΠΈΠΊ" }, items.map { it.show }.toString())
-        assertTrue(items.any { it.show == "Π‘Ρ‚ΠΎΡ€ΠΎΠΆ" })
-        assertTrue(items.any { it.show == "player" })
-    }
-
-    @Test
-    fun `a parameter with a known set of values offers them`() {
-        val source = "@hide-hud except=ch"
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertTrue(items.any { it.show == "chat" }, items.map { it.show }.toString())
-        assertTrue(items.none { it.show == "hotbar" }, "the typed prefix must filter the list")
-    }
-
-    @Test
-    fun `values are offered inside a list too, for the item being typed`() {
-        val source = "@hide-hud except=[chat, sub"
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertTrue(items.any { it.show == "subtitle_overlay" }, items.map { it.show }.toString())
-        assertTrue(items.none { it.show == "chat" })
-    }
-
-    @Test
-    fun `a parameter without a known set still completes parameter names`() {
-        val source = "@play-sound minecraft:x vol"
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", source, source.length)
-
-        assertTrue(items.any { it.show == "volume=" }, items.map { it.show }.toString())
-    }
-
-    @Test
-    fun `a parameter with a default shows the value it falls back to`() {
-        val items = StoryScriptingAnalyzer.collectCompletions("example.story", "@fade-", 6)
-        val fade = items.first { it.show == "@fade-in" } as CompletionItem.Declaration
-
-        assertEquals(" time=500 color=#000000", fade.middle)
-    }
-
-    @Test
-    fun `jumping to a local label resolves to its offset`() {
-        val source = "@jump #ΠšΠΎΠ½Π΅Ρ†\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·.\n# ΠšΠΎΠ½Π΅Ρ†\nΠ’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°."
-        val definition = StoryScriptingAnalyzer.definition("example.story", source, source.indexOf("#ΠšΠΎΠ½Π΅Ρ†") + 2)
-
-        assertNotNull(definition)
-        assertEquals("example.story", definition.path)
-        assertEquals(source.indexOf("# ΠšΠΎΠ½Π΅Ρ†") + 2, definition.offset)
-    }
-}
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt
index ba0eab392..8565d54e9 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/UiScriptContentTest.kt
@@ -6,15 +6,10 @@ import ru.hollowhorizon.hollowengine.client.slots.SlotGrid
 import ru.hollowhorizon.hollowengine.client.slots.Slots
 import ru.hollowhorizon.hollowengine.client.ui.*
 import ru.hollowhorizon.hollowengine.common.data.dataKey
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueChoiceView
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueChoicesView
-import ru.hollowhorizon.hollowengine.common.dialogue.DialogueUiKeys
 import ru.hollowhorizon.hollowengine.common.scripting.ui.UiScript
-import ru.hollowhorizon.hollowengine.common.ui.net.UiSurfaceKind
 import ru.hollowhorizon.hollowengine.common.utils.rl
 import kotlin.test.AfterTest
 import kotlin.test.Test
-import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 
 /**
@@ -58,8 +53,7 @@ class UiScriptContentTest {
         object : UiScript() {
             init {
                 overlay("mypack:block_hint") {
-                    anchor = "crosshair"
-                    placement = HudPlacement.AFTER
+                    anchor(after("crosshair"))
                     autoShow = true
                     content {
                         // A purely client-side overlay never calls send(); nothing forces a round trip.
@@ -101,54 +95,4 @@ class UiScriptContentTest {
 
         assertNotNull(UiDefinitionRegistry.screen("mypack:npc_gear".rl))
     }
-
-    @Test
-    fun `a surface switches between overlay and screen from its own document`() {
-        object : UiScript() {
-            init {
-                surface("mypack:dialogue") {
-                    anchor = "hotbar"
-                    interactive()
-                    closeOnEscape = false
-                    guiScale = UiGuiScale.Auto
-                    mode { document ->
-                        if (document[DialogueUiKeys.Choices].options.isEmpty()) UiSurfaceKind.OVERLAY
-                        else UiSurfaceKind.SCREEN
-                    }
-                    content {
-                        val line = data[DialogueUiKeys.Line]
-                        val choices = data[DialogueUiKeys.Choices]
-                        Box(mode = UiBoxMode.FREE, modifier = Modifier.size(UiLength.Fill, UiLength.Fill)) {
-                            var catcher: Modifier = Modifier
-                            if (line.awaiting) {
-                                catcher = catcher.input(clickable = true).onClick { event ->
-                                    if (event.isRightClick()) {
-                                        send { putString(DialogueUiKeys.Action, DialogueUiKeys.AdvanceAction) }
-                                    }
-                                }
-                            }
-                            Box(modifier = catcher)
-                            Box(mode = UiBoxMode.FREE) {
-                                Row {
-                                    if (line.speakerEntity >= 0) Entity(line.speakerEntity)
-                                    Text(line.text)
-                                }
-                                Box(modifier = if (line.awaiting) Modifier.state(UiState.of("ready")) else null) {
-                                    Image("hollowengine:textures/gui/icons/mouse_right.png")
-                                }
-                            }
-                            choices.options.forEach { option -> Text(option.text) }
-                        }
-                    }
-                }
-            }
-        }
-
-        val surface = assertNotNull(UiDefinitionRegistry.surface("mypack:dialogue".rl))
-        assertEquals(UiSurfaceKind.OVERLAY, surface.mode(UiData()), "a line reads over the world")
-        val menu = UiData().apply {
-            this[DialogueUiKeys.Choices] = DialogueChoicesView(listOf(DialogueChoiceView(0, "Yes")))
-        }
-        assertEquals(UiSurfaceKind.SCREEN, surface.mode(menu), "a menu needs the cursor")
-    }
 }
diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/FormattedTextParserTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/text/FormattedTextParserTest.kt
similarity index 91%
rename from runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/FormattedTextParserTest.kt
rename to runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/text/FormattedTextParserTest.kt
index 08bab906b..1fce52eef 100644
--- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/dialogue/FormattedTextParserTest.kt
+++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/ui/text/FormattedTextParserTest.kt
@@ -1,8 +1,8 @@
-package ru.hollowhorizon.hollowengine.common.dialogue
+package ru.hollowhorizon.hollowengine.addons.narrative.dialogue
 
-import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextAnimation
-import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextParser
-import ru.hollowhorizon.hollowengine.common.dialogue.text.FormattedTextStyle
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextAnimation
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextParser
+import ru.hollowhorizon.hollowengine.common.ui.text.FormattedTextStyle
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertIs
diff --git a/runtime/src/test/resources/dialogue/demo.story b/runtime/src/test/resources/dialogue/demo.story
deleted file mode 100644
index 8b2a359e5..000000000
--- a/runtime/src/test/resources/dialogue/demo.story
+++ /dev/null
@@ -1,142 +0,0 @@
-# Начало
-
-@look-at Π’ΠΈΡ‚Π°Π»ΠΈΠΊ player
-@play-once Π’ΠΈΡ‚Π°Π»ΠΈΠΊ hello
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘Ρ‚ΠΎΠΉ Π³Π΄Π΅ ΡΡ‚ΠΎΠΈΡˆΡŒ.
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’ΠΎΡ€ΠΎΡ‚Π° Π·Π°ΠΊΡ€Ρ‹Ρ‚Ρ‹ Π΄ΠΎ ΡƒΡ‚Ρ€Π°, Π° Ρ‚Ρ‹ Ρ‚ΡƒΡ‚ Π½Π΅ ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Π·Π° Π½ΠΎΡ‡ΡŒ.[-] Π§Π΅Π³ΠΎ Π½Π°Π΄ΠΎ?
-
-@if пропуск
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: А, это ΠΎΠΏΡΡ‚ΡŒ Ρ‚Ρ‹. ΠŸΡ€ΠΎΡ…ΠΎΠ΄ΠΈ, Ρ‡Π΅Π³ΠΎ ΡƒΠΆ.
-    @jump #ΠŸΡ€ΠΎΠΏΡƒΡΡ‚ΠΈΠ»
-
-@if ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΠΈ > 2
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘Π»ΡƒΡˆΠ°ΠΉ, Ρ‚Ρ‹ сюда Π½ΠΎΡ‡Π΅Π²Π°Ρ‚ΡŒ ΠΏΡ€ΠΈΡˆΡ‘Π»? Π’Ρ€Π΅Ρ‚ΠΈΠΉ Ρ€Π°Π· ΠΎΠ΄Π½ΠΎ ΠΈ Ρ‚ΠΎ ΠΆΠ΅.
-
-@set ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΠΈ = ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΠΈ + 1
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π£ тСбя с собой {money} ΠΌΠΎΠ½Π΅Ρ‚. ΠŸΡ€ΠΎΠΏΡƒΡΠΊ стоит Π΄Π²Π°Π΄Ρ†Π°Ρ‚ΡŒ.
-
-@choice "Π—Π°ΠΏΠ»Π°Ρ‚ΠΈΡ‚ΡŒ 20 ΠΌΠΎΠ½Π΅Ρ‚" id=pay if=money>=20
-    @jump #ΠžΠΏΠ»Π°Ρ‚Π°
-@choice "ΠŸΠΎΡ‚ΠΎΡ€Π³ΠΎΠ²Π°Ρ‚ΡŒΡΡ" id=haggle
-    @jump #Π’ΠΎΡ€Π³
-@choice "ΠŸΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ письмо ΠΊΠ°ΠΏΠΈΡ‚Π°Π½Π°" id=letter if=письмо
-    @jump #Письмо
-@choice "Π‘ΠΏΡ€ΠΎΡΠΈΡ‚ΡŒ ΠΏΡ€ΠΎ ΡˆΡ‚ΠΎΡ€ΠΌ" id=storm
-    @jump #Расспросы
-@choice "Π‘ΠΏΡ€ΠΎΡΠΈΡ‚ΡŒ, ΠΊΡ‚ΠΎ Π΅Ρ‰Ρ‘ ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΠ»" id=who
-    @jump #Расспросы
-@choice "Π£ΠΉΡ‚ΠΈ" id=leave icon="none"
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Иди-ΠΈΠ΄ΠΈ. И Π½Π΅ возвращайся Π΄ΠΎ рассвСта.
-    @jump #ΠšΠΎΠ½Π΅Ρ†
-
-# ΠžΠΏΠ»Π°Ρ‚Π°
-
-@set money = money - 20
-@set пропуск = true
-@play-sound minecraft:entity.villager.yes 1.0
-@play-once Π’ΠΈΡ‚Π°Π»ΠΈΠΊ hello
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’ΠΎΡ‚ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ Π΄Ρ€ΡƒΠ³ΠΎΠ΅ Π΄Π΅Π»ΠΎ.
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π”Π²Π°Π΄Ρ†Π°Ρ‚ΡŒ ΠΌΠΎΠ½Π΅Ρ‚, ΠΈ ΠΌΡ‹ Π΄Ρ€ΡƒΠ³ Π΄Ρ€ΡƒΠ³Π° Π½Π΅ Π²ΠΈΠ΄Π΅Π»ΠΈ.
-@jump #ΠŸΡ€ΠΎΠΏΡƒΡΡ‚ΠΈΠ»
-
-# Π’ΠΎΡ€Π³
-
-// Π’Π΅Ρ€ΠΏΠ΅Π½ΠΈΠ΅ стороТа Ρ‚ΠΈΠΊΠ°Π΅Ρ‚ Π² Ρ„ΠΎΠ½Π΅: ΠΊΠΎΠ³Π΄Π° ΠΎΠ½ΠΎ кончится, @sync ΠΏΠ΅Ρ€Π΅Ρ…Π²Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ Π΄ΠΈΠ°Π»ΠΎΠ³
-// прямо посрСди Ρ€Π΅ΠΏΠ»ΠΈΠΊΠΈ, Ρ‡Ρ‚ΠΎ Π±Ρ‹ ΠΈΠ³Ρ€ΠΎΠΊ Π² этот ΠΌΠΎΠΌΠ΅Π½Ρ‚ Π½ΠΈ Ρ‡ΠΈΡ‚Π°Π».
-@async name=Ρ‚Π΅Ρ€ΠΏΠ΅Π½ΠΈΠ΅
-    @wait 20s
-    @sync
-    @stop-looking Π’ΠΈΡ‚Π°Π»ΠΈΠΊ
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Всё, Π½Π°Π³ΠΎΠ²ΠΎΡ€ΠΈΠ»ΠΈΡΡŒ. ΠŸΡ€ΠΎΠ²Π°Π»ΠΈΠ²Π°ΠΉ.
-    @jump #ΠšΠΎΠ½Π΅Ρ†
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Ну ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉ. Волько быстро, Ρƒ мСня смСна.
-
-@choice "Π‘ΠΊΠΈΠ΄ΠΊΠ° Π·Π° красивыС Π³Π»Π°Π·Π°?" id=eyes
-    @cancel Ρ‚Π΅Ρ€ΠΏΠ΅Π½ΠΈΠ΅
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: НС смСши ΠΌΠΎΠΈ сапоги.
-    @jump #Начало
-@choice "Π― Π΄Ρ€ΡƒΠ³ ΠΊΠ°ΠΏΠΈΡ‚Π°Π½Π°" id=friend
-    @cancel Ρ‚Π΅Ρ€ΠΏΠ΅Π½ΠΈΠ΅
-    @look-at Π’ΠΈΡ‚Π°Π»ΠΈΠΊ [100, 65, 100] time=800
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ...ΠΊΠ°ΠΏΠΈΡ‚Π°Π½Π°, Π·Π½Π°Ρ‡ΠΈΡ‚.
-    @look-at Π’ΠΈΡ‚Π°Π»ΠΈΠΊ player
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠšΠ°ΠΏΠΈΡ‚Π°Π½ Π²Ρ‚ΠΎΡ€ΠΎΠΉ дСнь Π½Π΅ Π²Ρ‹Ρ…ΠΎΠ΄ΠΈΡ‚ ΠΈΠ· своСй ΠΊΠ°ΡŽΡ‚Ρ‹, ΠΈ Π΄Ρ€ΡƒΠ·Π΅ΠΉ Ρƒ Π½Π΅Π³ΠΎ Π½Π΅Ρ‚.[-] Но Π΄Π΅ΡΡΡ‚ΡŒ ΠΌΠΎΠ½Π΅Ρ‚ я Π²ΠΎΠ·ΡŒΠΌΡƒ.
-    @jump #ДСсятка
-@choice "ΠœΠΎΠ»Ρ‡Π° ΠΏΠΎΠ»ΠΎΠΆΠΈΡ‚ΡŒ Ρ€ΡƒΠΊΡƒ Π½Π° ΠΊΠΎΡˆΠ΅Π»Ρ‘ΠΊ" id=purse
-    @cancel Ρ‚Π΅Ρ€ΠΏΠ΅Π½ΠΈΠ΅
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’ΠΎΡ‚ это я понимаю Ρ€Π°Π·Π³ΠΎΠ²ΠΎΡ€.
-    @jump #ДСсятка
-
-# ДСсятка
-
-@if money >= 10
-    @set money = money - 10
-    @set пропуск = true
-    @play-sound minecraft:entity.villager.trade 1.0
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΡ€ΠΎΡ…ΠΎΠ΄ΠΈ, ΠΏΠΎΠΊΠ° я Π΄ΠΎΠ±Ρ€Ρ‹ΠΉ.
-    @jump #ΠŸΡ€ΠΎΠΏΡƒΡΡ‚ΠΈΠ»
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: И дСсятки Π½Π΅Ρ‚? Π’ΠΎΠ³Π΄Π° ΠΈ Ρ€Π°Π·Π³ΠΎΠ²ΠΎΡ€Π° Π½Π΅Ρ‚.
-@jump #ΠšΠΎΠ½Π΅Ρ†
-
-# Письмо
-
-@set ΠΏΠΎΠΆΠΈΡ‚ΠΊΠΈ = ΠΏΠΎΠΆΠΈΡ‚ΠΊΠΈ + "письмо"
-@play-once Π’ΠΈΡ‚Π°Π»ΠΈΠΊ hello
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Ну-ΠΊΠ°... ΠΏΠ΅Ρ‡Π°Ρ‚ΡŒ капитанская, Ρ‚ΠΎΡ‡Π½ΠΎ.
-@wait 700ms
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π›Π°Π΄Π½ΠΎ. ΠŸΡ€ΠΎΡ…ΠΎΠ΄ΠΈ, Π½ΠΎ письмо я Π·Π°ΠΏΠΎΠΌΠ½ΠΈΠ».
-@set пропуск = true
-@jump #ΠŸΡ€ΠΎΠΏΡƒΡΡ‚ΠΈΠ»
-
-# Расспросы
-
-@set Π»ΡŽΠ±ΠΎΠΏΡ‹Ρ‚ΡΡ‚Π²ΠΎ = Π»ΡŽΠ±ΠΎΠΏΡ‹Ρ‚ΡΡ‚Π²ΠΎ + 1
-
-@if Π»ΡŽΠ±ΠΎΠΏΡ‹Ρ‚ΡΡ‚Π²ΠΎ == 1
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π¨Ρ‚ΠΎΡ€ΠΌ Ρ‚Ρ€Π΅Ρ‚ΠΈΠΉ дСнь Π½Π΅ унимаСтся. Π’Ρ€ΠΈ Π»ΠΎΠ΄ΠΊΠΈ ΡƒΠΆΠ΅ Π½Π΅ Π²Π΅Ρ€Π½ΡƒΠ»ΠΈΡΡŒ.
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠŸΠΎΡΡ‚ΠΎΠΌΡƒ ΠΈ Π²ΠΎΡ€ΠΎΡ‚Π° Π·Π°ΠΊΡ€Ρ‹Ρ‚Ρ‹, Π° Π½Π΅ ΠΏΠΎΡ‚ΠΎΠΌΡƒ Ρ‡Ρ‚ΠΎ ΠΌΠ½Π΅ Ρ‚Π°ΠΊ вСсСло.
-
-@if Π»ΡŽΠ±ΠΎΠΏΡ‹Ρ‚ΡΡ‚Π²ΠΎ == 2
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: ΠšΡ‚ΠΎ ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΠ»? Π”Π° Π½ΠΈΠΊΡ‚ΠΎ с ΠΏΠΎΠ»ΡƒΠ½ΠΎΡ‡ΠΈ.
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π Π°Π·Π²Π΅ Ρ‡Ρ‚ΠΎ ΡΠΌΠΎΡ‚Ρ€ΠΈΡ‚Π΅Π»ΡŒ маяка, Π½ΠΎ ΠΎΠ½ Ρ‚ΡƒΡ‚ ΠΊΠ°ΠΆΠ΄ΡƒΡŽ Π½ΠΎΡ‡ΡŒ Ρ…ΠΎΠ΄ΠΈΡ‚.
-
-@if Π»ΡŽΠ±ΠΎΠΏΡ‹Ρ‚ΡΡ‚Π²ΠΎ > 2
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π₯Π²Π°Ρ‚ΠΈΡ‚ с тСбя вопросов.
-
-@jump #Начало
-
-# ΠŸΡ€ΠΎΠΏΡƒΡΡ‚ΠΈΠ»
-
-@set ΠΏΠΎΠΆΠΈΡ‚ΠΊΠΈ = ΠΏΠΎΠΆΠΈΡ‚ΠΊΠΈ + "пропуск"
-
-@hide-hud
-@fade-in 600ms #000000
-
-@async name=ΠΎΡ‚Ρ…ΠΎΠ΄
-    @walk-to Π’ΠΈΡ‚Π°Π»ΠΈΠΊ [25, 70, -175] speed=1.1 distance=0.6 timeout=6s
-    @look-at Π’ΠΈΡ‚Π°Π»ΠΈΠΊ player
-
-@camera location=[25, 70, -171] rotation=[-158, 2] time=1500 fov=60
-@fade-out 600ms
-@wait 900ms
-
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π’ΠΎΡ€ΠΎΡ‚Π° ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹. НС задСрТивайся Π½Π° ΠΏΡ€ΠΈΡ‡Π°Π»Π΅.
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: И Ссли ΡƒΠ²ΠΈΠ΄ΠΈΡˆΡŒ смотритСля - скаТи, Ρ‡Ρ‚ΠΎ я Π΅Π³ΠΎ ΠΆΠ΄Ρƒ.
-
-@await ΠΎΡ‚Ρ…ΠΎΠ΄
-@release-camera 800
-@show-hud
-
-# ΠšΠΎΠ½Π΅Ρ†
-
-@if ΠΏΠΎΠΆΠΈΡ‚ΠΊΠΈ.size > 0
-    Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘ собой Ρƒ тСбя: {ΠΏΠΎΠΆΠΈΡ‚ΠΊΠΈ[0]}.
-
-@stop-looking Π’ΠΈΡ‚Π°Π»ΠΈΠΊ
-Π’ΠΈΡ‚Π°Π»ΠΈΠΊ: Π‘Ρ‹Π²Π°ΠΉ.