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 b4a0af0a6..16ecdd190 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 @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.common.compiler import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.compiling.HollowEngineScriptEvaluator import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptResult +import ru.hollowhorizon.hollowengine.common.scripting.compiling.convert import ru.hollowhorizon.hollowengine.common.scripting.compiling.isClientSideScript import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptEvaluationException import kotlin.reflect.KClass diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/ScriptingCompiler.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/ScriptingCompiler.kt index c1ea79f30..220e6d661 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/ScriptingCompiler.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/compiler/ScriptingCompiler.kt @@ -8,6 +8,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptFingerprint import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptCompilationContext import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptingCompiler +import ru.hollowhorizon.hollowengine.common.scripting.compiling.convert import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.RemappingClasspath import ru.hollowhorizon.hollowengine.common.scripting.ide.* import ru.hollowhorizon.hollowengine.common.scripting.mixins.MixinSpecLayout @@ -146,18 +147,3 @@ fun List.getDefinitionFor(name: String): Sc ) } } - -fun ScriptDiagnostic.convert(): Diagnostic { - return Diagnostic(this.location?.let { - Range( - Position(it.start.line, it.start.col), - Position(it.end?.line ?: it.start.line, it.end?.col ?: it.start.col) - ) - } ?: Range(Position(-1, -1), Position(-1, -1)), when (this.severity) { - ScriptDiagnostic.Severity.DEBUG -> Severity.DEBUG - ScriptDiagnostic.Severity.INFO -> Severity.INFO - ScriptDiagnostic.Severity.WARNING -> Severity.WARNING - ScriptDiagnostic.Severity.ERROR -> Severity.ERROR - ScriptDiagnostic.Severity.FATAL -> Severity.FATAL - }, this.message) -} 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 0cb1298b4..acf9c8909 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 @@ -23,6 +23,9 @@ import ru.hollowhorizon.hollowengine.common.ide.session.insight.findSignatureHel import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaRekotLibraryModule import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaScriptModule import ru.hollowhorizon.hollowengine.common.scripting.ide.* +import ru.hollowhorizon.hollowengine.common.ide.session.symbols.searchSymbols +import ru.hollowhorizon.hollowengine.common.ide.session.symbols.symbolSource +import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry import java.io.File @@ -303,6 +306,21 @@ class ScriptingAnalyzerImpl( val file = getOrCreateFile(name, text) return collectImportDiagnostics(name, file) + diagnosticCode(file) } + + @Synchronized + override fun searchSymbols(query: String, limit: Int): List { + return searchSymbols(getOrCreateFile(SymbolLookupScript, ""), query, limit) + } + + @Synchronized + override fun symbolSource(qualifiedName: String): DefinitionLocation? { + return symbolSource(getOrCreateFile(SymbolLookupScript, ""), qualifiedName) + } + + private companion object { + /** An empty sandbox script, so name lookups see the classpath the project's own scripts compile against. */ + val SymbolLookupScript = "${SandboxScriptSource.SCRIPTS_DIRECTORY}/.symbol-lookup.kts" + } } private fun removeFromPsiManager(file: KtFile) { 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 1fa8902db..9484dd17d 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 @@ -395,7 +395,7 @@ private fun CompletionItemsCollector.completeScopeContext(file: KtFile, element: } context(session: KaSession) -private fun getKotlinDeclarationsFromIndex(ktFile: KtFile, filter: (Name) -> Boolean): Sequence { +internal fun getKotlinDeclarationsFromIndex(ktFile: KtFile, filter: (Name) -> Boolean): Sequence { with(session) { val factory = KotlinDeclarationProviderFactory.getInstance(ktFile.project) val declarationProvider = factory.createDeclarationProvider(analysisScope, useSiteModule) diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/definition/DefinitionProvider.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/definition/DefinitionProvider.kt index f3548bd75..aa27766fc 100644 --- a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/definition/DefinitionProvider.kt +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/definition/DefinitionProvider.kt @@ -54,7 +54,7 @@ private fun List.pickDefinitionSymbol(): KaDeclarationSymbol? { ?: firstNotNullOfOrNull { it as? KaDeclarationSymbol } } -private fun ScriptingAnalyzerImpl.definitionForSymbol(symbol: KaDeclarationSymbol): DefinitionLocation? { +internal fun ScriptingAnalyzerImpl.definitionForSymbol(symbol: KaDeclarationSymbol): DefinitionLocation? { val targetPsi = symbol.psi runCatching { targetPsi?.toDefinitionLocation()?.let { return it } @@ -174,7 +174,7 @@ private object CfrDefinitionDecompiler { if (!classPath.endsWith(".class") || "!/" !in classPath) return null val key = classPath val text = cache.getOrPut(key) { - decompileClass(classPath) ?: return null + decompileClass(classFile) ?: return null } return DefinitionLocation( path = "decompiled/${classPath.substringAfterLast("!/").removeSuffix(".class")}.java", @@ -184,25 +184,21 @@ private object CfrDefinitionDecompiler { ) } - private fun decompileClass(classPath: String): String? { - val jarPath = classPath.substringBefore("!/") - val entryName = classPath.substringAfter("!/") + /** + * Copies [classFile] and its nested classes out of whatever file system holds them, a jar or the + * JDK's module image, into a directory CFR can read, and decompiles the outer class there. + */ + private fun decompileClass(classFile: VirtualFile): String? { + val entryName = classFile.path.substringAfterLast("!/") + val outerClass = classFile.nameWithoutExtension.substringBefore('$') + val siblings = classFile.parent?.children.orEmpty() + .filter { it.extension == "class" && it.nameWithoutExtension.substringBefore('$') == outerClass } val tempRoot = Files.createTempDirectory("hollowengine-cfr-") return try { - JarFile(File(jarPath)).use { jar -> - val outerClassPrefix = entryName.removeSuffix(".class").substringBefore('$') - jar.entries().asSequence() - .filter { !it.isDirectory && it.name.endsWith(".class") && it.name.removeSuffix(".class").substringBefore('$') == outerClassPrefix } - .forEach { entry -> - val target = tempRoot.resolve(entry.name) - Files.createDirectories(target.parent) - jar.getInputStream(entry).use { input -> - Files.copy(input, target) - } - } - } - val targetClass = tempRoot.resolve(entryName) - CfrSourceDecompiler.decompile(targetClass.absolutePathString()) + val packageDirectory = tempRoot.resolve(entryName).parent + Files.createDirectories(packageDirectory) + siblings.forEach { sibling -> Files.write(packageDirectory.resolve(sibling.name), sibling.contentsToByteArray()) } + CfrSourceDecompiler.decompile(tempRoot.resolve(entryName).absolutePathString()) } finally { tempRoot.toFile().deleteRecursively() } diff --git a/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/symbols/SymbolSearch.kt b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/symbols/SymbolSearch.kt new file mode 100644 index 000000000..4c9d83b16 --- /dev/null +++ b/addons/compiler/src/main/java/ru/hollowhorizon/hollowengine/common/ide/session/symbols/SymbolSearch.kt @@ -0,0 +1,187 @@ +package ru.hollowhorizon.hollowengine.common.ide.session.symbols + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaDeclarationSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolVisibility +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaTypeParameterOwnerSymbol +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.types.Variance +import ru.hollowhorizon.hollowengine.common.ide.session.ScriptingAnalyzerImpl +import ru.hollowhorizon.hollowengine.common.ide.session.completion.getKotlinDeclarationsFromIndex +import ru.hollowhorizon.hollowengine.common.ide.session.definition.definitionForSymbol +import ru.hollowhorizon.hollowengine.common.ide.session.index.JavaClassNameIndex +import ru.hollowhorizon.hollowengine.common.scripting.ide.DefinitionLocation +import ru.hollowhorizon.hollowengine.common.scripting.ide.SymbolKind +import ru.hollowhorizon.hollowengine.common.scripting.ide.SymbolMatch + +/** + * Classes from the class-file index and Kotlin top-level declarations from the declaration index, both + * seen from [file], ranked by how closely their simple name matches the query. + */ +internal fun ScriptingAnalyzerImpl.searchSymbols(file: KtFile, query: String, limit: Int): List { + val pattern = SymbolQuery.parse(query) ?: return emptyList() + val found = LinkedHashMap() + + fun offer(rank: Int, match: SymbolMatch) { + val key = match.qualifiedName + match.signature + val existing = found[key] + if (existing == null || rank < existing.rank) found[key] = RankedMatch(rank, match) + } + + analyze(file) { + getKotlinDeclarationsFromIndex(file) { name -> pattern.rank(name) != null }.forEach { symbol -> + val match = symbolMatch(symbol) ?: return@forEach + if (!pattern.qualifies(match.qualifiedName)) return@forEach + val rank = pattern.rank(match.qualifiedName.substringAfterLast('.')) ?: return@forEach + offer(rank, match) + } + } + + JavaClassNameIndex.getInstance(project).classes({ name -> pattern.rank(name) != null }).forEach { javaClass -> + if (javaClass.name.asString().first().isDigit() || !pattern.qualifies(javaClass.fqName)) return@forEach + val rank = pattern.rank(javaClass.name) ?: return@forEach + val kind = if (javaClass.isAnnotation) SymbolKind.ANNOTATION else SymbolKind.CLASS + offer(rank, SymbolMatch(kind, javaClass.fqName)) + } + + return found.values + .sortedWith( + compareBy { it.rank } + .thenBy { originOrder(it.match.qualifiedName) } + .thenBy { it.match.kind } + .thenBy { it.match.qualifiedName.length } + .thenBy { it.match.qualifiedName }, + ) + .take(limit) + .map(RankedMatch::match) +} + +/** + * The declaration [qualifiedName] names: a class (nested ones spelled with dots), a member of one, or a + * top-level function or property. Overloads resolve to the first one declared. + */ +internal fun ScriptingAnalyzerImpl.symbolSource(file: KtFile, qualifiedName: String): DefinitionLocation? { + val fqName = runCatching { FqName(qualifiedName.trim()) }.getOrNull() ?: return null + if (fqName.isRoot) return null + val symbol = analyze(file) { + findClassNamed(fqName) + ?: findMember(fqName) + ?: findTopLevelCallables(fqName.parent(), fqName.shortName()).firstOrNull() + } ?: return null + return definitionForSymbol(symbol) +} + +private class RankedMatch(val rank: Int, val match: SymbolMatch) + +/** Among equally close names, the game's and the engine's come first and the JDK's internals last. */ +private fun originOrder(qualifiedName: String): Int = when { + PREFERRED_PACKAGES.any(qualifiedName::startsWith) -> 0 + INTERNAL_PACKAGES.any(qualifiedName::startsWith) -> 2 + else -> 1 +} + +private val PREFERRED_PACKAGES = listOf("net.minecraft.", "com.mojang.", "ru.hollowhorizon.") +private val INTERNAL_PACKAGES = listOf("sun.", "com.sun.", "jdk.", "org.w3c.", "org.xml.", "javax.swing.", "java.awt.") + +/** A query split into the simple name to match and the part of the qualifier it was written with. */ +private class SymbolQuery(private val name: String, private val qualifier: String) { + /** Lower is closer; null when [simpleName] does not match at all. */ + fun rank(simpleName: Name): Int? = rank(simpleName.asString()) + + fun rank(simpleName: String): Int? = when { + simpleName == name -> 0 + simpleName.equals(name, ignoreCase = true) -> 1 + simpleName.startsWith(name, ignoreCase = true) -> 2 + simpleName.contains(name, ignoreCase = true) -> 3 + else -> null + } + + fun qualifies(qualifiedName: String): Boolean = + qualifier.isEmpty() || qualifiedName.substringBeforeLast('.', "").contains(qualifier, ignoreCase = true) + + companion object { + fun parse(query: String): SymbolQuery? { + val trimmed = query.trim().trim('.') + val name = trimmed.substringAfterLast('.') + if (name.isEmpty()) return null + return SymbolQuery(name, trimmed.substringBeforeLast('.', "")) + } + } +} + +/** Null for what a script cannot reach: anything not public, and local or anonymous declarations. */ +context(session: KaSession) +private fun symbolMatch(symbol: KaDeclarationSymbol): SymbolMatch? = with(session) { + if (symbol.visibility != KaSymbolVisibility.PUBLIC) return null + when (symbol) { + is KaClassLikeSymbol -> SymbolMatch(SymbolKind.CLASS, symbol.classId?.asFqNameString() ?: return null) + is KaCallableSymbol -> SymbolMatch( + kind = if (symbol is KaFunctionSymbol) SymbolKind.FUNCTION else SymbolKind.PROPERTY, + qualifiedName = symbol.callableId?.asSingleFqName()?.asString() ?: return null, + signature = signature(symbol), + ) + else -> null + } +} + +/** + * A one-line declaration with its receiver and type parameters, and `= …` after every parameter that + * has a default, which is what tells a caller which arguments it can leave out. + */ +context(session: KaSession) +private fun signature(symbol: KaCallableSymbol): String = with(session) { + fun render(type: KaType) = type.render(KaTypeRendererForSource.WITH_SHORT_NAMES, Variance.INVARIANT) + buildString { + append( + when (symbol) { + is KaFunctionSymbol -> "fun " + is KaPropertySymbol -> if (symbol.isVal) "val " else "var " + else -> "" + } + ) + val typeParameters = (symbol as? KaTypeParameterOwnerSymbol)?.typeParameters.orEmpty() + if (typeParameters.isNotEmpty()) { + append(typeParameters.joinToString(", ", "<", "> ") { it.name.asString() }) + } + symbol.receiverParameter?.let { receiver -> append(render(receiver.returnType)).append('.') } + append(symbol.callableId?.callableName?.asString().orEmpty()) + if (symbol is KaFunctionSymbol) { + append(symbol.valueParameters.joinToString(", ", "(", ")") { parameter -> + val vararg = if (parameter.isVararg) "vararg " else "" + val default = if (parameter.hasDefaultValue) " = …" else "" + "$vararg${parameter.name.asString()}: ${render(parameter.returnType)}$default" + }) + } + append(": ").append(render(symbol.returnType)) + } +} + +/** Tries every split of [fqName] into package and class, the longest package first. */ +context(session: KaSession) +private fun findClassNamed(fqName: FqName): KaClassSymbol? = with(session) { + val segments = fqName.pathSegments() + for (packageSize in segments.size - 1 downTo 0) { + val packageFqName = FqName.fromSegments(segments.take(packageSize).map(Name::asString)) + val relativeName = FqName.fromSegments(segments.drop(packageSize).map(Name::asString)) + findClass(ClassId(packageFqName, relativeName, isLocal = false))?.let { return it } + } + null +} + +context(session: KaSession) +private fun findMember(fqName: FqName): KaDeclarationSymbol? = with(session) { + val owner = findClassNamed(fqName.parent()) ?: return null + val scope = owner.combinedDeclaredMemberScope + scope.callables(fqName.shortName()).firstOrNull() ?: scope.classifiers(fqName.shortName()).firstOrNull() +} diff --git a/addons/compiler/src/test/kotlin/SymbolLookupTest.kt b/addons/compiler/src/test/kotlin/SymbolLookupTest.kt new file mode 100644 index 000000000..bf5899b8e --- /dev/null +++ b/addons/compiler/src/test/kotlin/SymbolLookupTest.kt @@ -0,0 +1,64 @@ +import org.junit.jupiter.api.AfterAll +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 ru.hollowhorizon.hollowengine.common.scripting.ide.SymbolKind +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SymbolLookupTest { + 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("kts", "kotlin.Any")), + mappings = Mappings.EMPTY, + ) + } + + private val analyzer get() = fixture.environment.analyzer + + @BeforeAll + fun startEnvironment() = fixture.start() + + @AfterAll + fun closeEnvironment() = fixture.close() + + @Test + fun `an exact name ranks before names that only contain it`() { + val matches = analyzer.searchSymbols("ScriptClassProvider", 20) + assertEquals("ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider", matches.first().qualifiedName) + assertEquals(SymbolKind.CLASS, matches.first().kind) + } + + @Test + fun `extension functions show their receiver and which parameters have defaults`() { + val match = analyzer.searchSymbols("collections.joinToString", 20) + .first { it.kind == SymbolKind.FUNCTION && it.qualifiedName == "kotlin.collections.joinToString" } + assertTrue(".joinToString(separator: CharSequence = …" in match.signature, match.signature) + } + + @Test + fun `a qualifier narrows the search to matching packages`() { + val matches = analyzer.searchSymbols("util.ArrayList", 50) + assertTrue(matches.isNotEmpty()) + assertTrue(matches.all { "util" in it.qualifiedName.substringBeforeLast('.') }, matches.toString()) + } + + @Test + fun `sources resolve classes, nested classes, members and top-level functions`() { + val member = assertNotNull(analyzer.symbolSource("java.util.ArrayList.trimToSize")) + assertTrue(member.text!!.startsWith("trimToSize", member.offset), member.path) + assertNotNull(analyzer.symbolSource("java.util.Map.Entry")) + assertNotNull(analyzer.symbolSource("kotlin.collections.joinToString")) + assertNull(analyzer.symbolSource("no.such.Thing")) + } +} diff --git a/addons/mcp/build.gradle.kts b/addons/mcp/build.gradle.kts new file mode 100644 index 000000000..bbb0d461a --- /dev/null +++ b/addons/mcp/build.gradle.kts @@ -0,0 +1,37 @@ +base { + archivesName.set("HollowEngineMcp") +} + +val mcpSdkVersion = "0.15.0" +val ktorVersion = "3.5.1" + +dependencies { + add("addonLibraries", "io.modelcontextprotocol:kotlin-sdk-server:$mcpSdkVersion") + add("addonLibraries", "io.ktor:ktor-server-cio:$ktorVersion") +} + +val docsDirectory = rootProject.layout.projectDirectory.dir("docs/en") +val docsPageList = tasks.register("docsPageList") { + val pages = docsDirectory + val output = layout.buildDirectory.file("generated/docs/pages.txt") + inputs.dir(pages) + outputs.file(output) + doLast { + val root = pages.asFile + val paths = root.walkTopDown() + .filter { it.isFile && it.extension == "mdx" } + .map { it.relativeTo(root).invariantSeparatorsPath } + .sorted() + output.get().asFile.writeText(paths.joinToString("\n")) + } +} + +tasks.named("processResources") { + from(docsDirectory) { + into("hollowengine-mcp/docs") + include("**/*.mdx") + } + from(docsPageList) { + into("hollowengine-mcp/docs") + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/AgentClient.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/AgentClient.kt new file mode 100644 index 000000000..d76b81f90 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/AgentClient.kt @@ -0,0 +1,35 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +/** The agents `/he mcp` can copy a connection for; each spells the same address and token its own way. */ +internal enum class AgentClient(val id: String, val title: String, val color: Int, val hint: String) { + CLAUDE_CODE("claude", "Claude Code", 0xD97757, "A terminal command that adds this server to Claude Code") { + override fun connection(url: String, token: String) = + "claude mcp add --transport http hollowengine $url --header \"Authorization: Bearer $token\"" + }, + CODEX("codex", "Codex", 0x10A37F, "A block for ~/.codex/config.toml") { + override fun connection(url: String, token: String) = """ + [mcp_servers.hollowengine] + url = "$url" + http_headers = { "Authorization" = "Bearer $token" } + """.trimIndent() + }, + JSON("json", "JSON", 0xE5C07B, "An mcpServers entry for Cursor and other clients that read one") { + override fun connection(url: String, token: String) = """ + { + "mcpServers": { + "hollowengine": { + "url": "$url", + "headers": { "Authorization": "Bearer $token" } + } + } + } + """.trimIndent() + }, + TOKEN("token", "Token", 0x8F9BAD, "The access token alone") { + override fun connection(url: String, token: String) = token + }; + + abstract fun connection(url: String, token: String): String + + val hintKey: String get() = "hollowengine_mcp.command.hint.$id" +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/HollowMcpAddon.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/HollowMcpAddon.kt new file mode 100644 index 000000000..2e9833aa4 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/HollowMcpAddon.kt @@ -0,0 +1,86 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.minecraft.network.chat.Component +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.mcp.client.screenshotTool +import ru.hollowhorizon.hollowengine.addons.mcp.docs.DocsLibrary +import ru.hollowhorizon.hollowengine.addons.mcp.tools.codeTools +import ru.hollowhorizon.hollowengine.addons.mcp.tools.docsTools +import ru.hollowhorizon.hollowengine.addons.mcp.tools.runCommandTool +import ru.hollowhorizon.hollowengine.addons.mcp.tools.runSnippetTool +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +class HollowMcpAddon : HollowAddonEntrypoint { + @Volatile + private var server: McpServer? = null + + /** Why the server is not running, when it is not. */ + @Volatile + private var status: Component = Component.translatableWithFallback( + "hollowengine_mcp.command.status.starting", "The MCP server has not started yet", + ) + + private val command = McpCommand(server = { server }, status = { status }) + + override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { + McpConfig.initialize() + if (!McpConfig.enabled) { + status = Component.translatableWithFallback( + "hollowengine_mcp.command.status.disabled", + "The server is off: set enabled = true in config/hollowengine-mcp.toml and restart the game", + ) + HollowEngine.LOGGER.info("The MCP server is off in config/hollowengine-mcp.toml") + return + } + + val docs = DocsLibrary.load(context.classLoader, DOCS_ROOT) + val tools = buildList { + add(runCommandTool()) + add(runSnippetTool()) + addAll(codeTools()) + addAll(docsTools(docs)) + if (isPhysicalClient) add(screenshotTool()) + } + val instructions = McpInstructions(context.classLoader) + val mcp = McpServer(context.descriptor.version, tools, instructions::render) + val port = McpConfig.port + val started = withContext(Dispatchers.IO) { + runCatching { mcp.start(port, McpConfig.ensureToken(), context.classLoader) } + } + started.onFailure { error -> + status = Component.translatableWithFallback( + "hollowengine_mcp.command.status.port_taken", + "Port %s is taken; pick another in config/hollowengine-mcp.toml and restart the game", + port, + ) + HollowEngine.LOGGER.error("The MCP server could not listen on port {}", port, error) + return + } + server = mcp + HollowEngine.LOGGER.info("MCP server listening on {}; /he mcp copies the connection for an agent", mcp.url) + } + + override suspend fun unload(context: HollowAddonContext) { + withContext(Dispatchers.IO) { server?.stop() } + server = null + } + + @SubscribeEvent(-1) + fun registerCommands(event: RegisterCommandsEvent) { + val root = requireNotNull(event.dispatcher.root.getChild("hollowengine")) { + "The HollowEngine root command must be registered before addon commands" + } + command.register(root) + } + + private companion object { + const val DOCS_ROOT = "hollowengine-mcp/docs" + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpCommand.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpCommand.kt new file mode 100644 index 000000000..158a66a64 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpCommand.kt @@ -0,0 +1,119 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +import com.mojang.brigadier.tree.CommandNode +import net.minecraft.ChatFormatting +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.network.chat.ClickEvent +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.HoverEvent +import net.minecraft.network.chat.MutableComponent +import ru.hollowhorizon.hollowengine.addons.mcp.client.copyToClipboard +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +/** + * `/he mcp` shows whether the server runs and offers the connection for each [AgentClient]; + * `/he mcp copy ` puts it on the clipboard. The connection carries the token, so it never + * appears in chat or in the log: only the host of a local world can copy it, straight into this + * game's clipboard, since the server listens on this computer alone. + */ +internal class McpCommand(private val server: () -> McpServer?, private val status: () -> Component) { + fun register(root: CommandNode) { + val copy = Commands.literal("copy") + AgentClient.entries.forEach { client -> + copy.then(Commands.literal(client.id).executes { context -> copy(context.source, client) }) + } + val command = Commands.literal("mcp") + .requires { source -> isHost(source) || source.hasPermission(4) } + .executes { context -> show(context.source) } + .then(copy) + root.addChild(command.build()) + } + + private fun show(source: CommandSourceStack): Int { + val mcp = server() + val url = mcp?.url + val rows = buildList { + add(title(running = url != null, url)) + when { + url == null -> add(status().copy().withStyle(ChatFormatting.GRAY)) + isHost(source) -> { + add(text("copy", "Copy the connection for your agent:").withStyle(ChatFormatting.GRAY)) + add(buttons()) + } + else -> add( + text( + "elsewhere", + "Agents connect on the machine this game runs on, with the token from config/hollowengine-mcp.toml", + ).withStyle(ChatFormatting.GRAY) + ) + } + } + source.sendSystemMessage(panel(rows)) + return if (url != null) 1 else 0 + } + + private fun copy(source: CommandSourceStack, client: AgentClient): Int { + if (!isHost(source)) { + source.sendFailure(text("host_only", "Only the host of a local world can copy the connection: agents reach it on this computer alone")) + return 0 + } + val url = server()?.url ?: return 0.also { source.sendFailure(status()) } + copyToClipboard(client.connection(url, McpConfig.token)) + source.player?.displayClientMessage( + text("copied", "Copied the connection for %s", client.title).withStyle(ChatFormatting.GREEN), + true, + ) + return 1 + } + + /** Whether [source] is the player hosting this very game's world, whose clipboard is this computer's. */ + private fun isHost(source: CommandSourceStack): Boolean { + if (!isPhysicalClient) return false + val player = source.player ?: return false + return source.server.isSingleplayerOwner(player.gameProfile) + } + + private fun title(running: Boolean, url: String?): MutableComponent { + val state = if (running) text("running", "running") else text("stopped", "stopped") + return Component.literal("HollowEngine MCP").withStyle(ChatFormatting.BOLD, ChatFormatting.WHITE) + .append(Component.literal(" ● ").withStyle(if (running) ChatFormatting.GREEN else ChatFormatting.RED)) + .append(state.withStyle(if (running) ChatFormatting.GREEN else ChatFormatting.RED)) + .apply { if (url != null) append(Component.literal(" $url").withStyle(ChatFormatting.DARK_GRAY)) } + } + + private fun buttons(): MutableComponent { + val row = Component.empty() + AgentClient.entries.forEachIndexed { index, client -> + if (index > 0) row.append(Component.literal(" ")) + val hint = Component.translatableWithFallback(client.hintKey, client.hint) + .append(Component.literal("\n")) + .append(text("hint_token", "Carries the access token").withStyle(ChatFormatting.DARK_GRAY)) + row.append( + Component.literal("[ ${client.title} ]").withStyle { style -> + style.withColor(client.color).withBold(true) + .withClickEvent(ClickEvent(ClickEvent.Action.RUN_COMMAND, "/hollowengine mcp copy ${client.id}")) + .withHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT, hint)) + } + ) + } + return row + } + + /** Rows set off from the rest of the chat by an accent bar down their left edge. */ + private fun panel(rows: List): Component { + val panel = Component.empty() + rows.forEachIndexed { index, row -> + if (index > 0) panel.append(Component.literal("\n")) + panel.append(Component.literal("▍ ").withColor(ACCENT)).append(row) + } + return panel + } + + private fun text(key: String, fallback: String, vararg args: Any): MutableComponent = + Component.translatableWithFallback("hollowengine_mcp.command.$key", fallback, *args) + + private companion object { + const val ACCENT = 0x4C86E0 + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpConfig.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpConfig.kt new file mode 100644 index 000000000..d5311b880 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpConfig.kt @@ -0,0 +1,34 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +import ru.hollowhorizon.hollowengine.common.config.Config +import ru.hollowhorizon.hollowengine.common.config.ConfigName +import ru.hollowhorizon.hollowengine.common.config.PropertyComment +import ru.hollowhorizon.hollowengine.common.config.PropertyName +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient +import java.security.SecureRandom +import java.util.Base64 + +@ConfigName("hollowengine-mcp") +object McpConfig : Config() { + @PropertyComment("Starts the MCP server coding agents connect to. Off by default on dedicated servers") + @PropertyName("enabled") + var enabled by property(isPhysicalClient) + + @PropertyComment("Port the server listens on. It only ever binds to 127.0.0.1") + @PropertyName("port") + var port by property(25590) + + @PropertyComment("Agents send it as 'Authorization: Bearer '. Generated when empty; clear it to issue a new one") + @PropertyName("token") + var token by property("") + + /** The token, generated and saved on the first start. */ + fun ensureToken(): String { + token.takeIf(String::isNotBlank)?.let { return it } + val bytes = ByteArray(24).also(SecureRandom()::nextBytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).also { generated -> + token = generated + save(force = true) + } + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpInstructions.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpInstructions.kt new file mode 100644 index 000000000..8eb2905e1 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpInstructions.kt @@ -0,0 +1,45 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +import ru.hollowhorizon.hollowengine.addons.mcp.tools.ServerAccess +import ru.hollowhorizon.hollowengine.common.files.DirectoryManager +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +const val COMPILER_MISSING = "The Kotlin compiler addon is not installed in this game, so Kotlin code can be " + + "neither analyzed nor compiled. Ask the user to put the HollowEngineCompiler jar from the same " + + "HollowEngine release into the mods folder (or hollowengine/addons) and restart the game." + +/** + * What an agent reads when it connects: the working guide shipped as `instructions.md` next to the + * docs, with this game's paths filled in, followed by the game as it is right now. + */ +internal class McpInstructions(classLoader: ClassLoader) { + private val guide = classLoader.getResourceAsStream(GUIDE)?.bufferedReader()?.use { it.readText() } + ?.replace("{scripts}", DirectoryManager.HOLLOW_ENGINE.resolve("scripts").toString()) + ?.replace("{log}", DirectoryManager.HOLLOW_ENGINE.resolveSibling("logs").resolve("latest.log").toString()) + ?.trimEnd() + .orEmpty() + + fun render(): String = buildString { + appendLine(guide) + appendLine() + appendLine("## Right now") + appendLine() + appendLine("This is ${sideDescription()}.") + if (!ScriptingEnvironment.isAvailable()) appendLine(COMPILER_MISSING) + }.trimEnd() + + private fun sideDescription(): String { + val server = ServerAccess.current() + return when { + !isPhysicalClient -> "a dedicated server" + server != null -> "a game client with a local world open; screenshot is available" + else -> "a game client in the menu or on a remote server, so only client-side tools and snippets " + + "work until a local world is opened; screenshot is available" + } + } + + private companion object { + const val GUIDE = "hollowengine-mcp/instructions.md" + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpServer.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpServer.kt new file mode 100644 index 000000000..66fd01a9d --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpServer.kt @@ -0,0 +1,104 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCallPipeline +import io.ktor.server.application.call +import io.ktor.server.cio.CIO +import io.ktor.server.cio.CIOApplicationEngine +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.response.respondText +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations +import kotlinx.coroutines.CancellationException +import ru.hollowhorizon.hollowengine.HollowEngine +import java.security.MessageDigest + +/** + * The HTTP endpoint agents connect to. Every agent connection opens its own MCP session, and all of + * them share [toolset]; [instructions] is asked again for each session, so it reports the game as it is + * when the agent connects. + */ +class McpServer( + private val version: String, + private val toolset: List, + private val instructions: () -> String, +) { + private var engine: EmbeddedServer? = null + private var boundPort = 0 + + /** Where agents connect, or null while the server is not running. */ + val url: String? get() = engine?.let { "http://$HOST:$boundPort$PATH" } + + /** Binds [port] on the loopback interface; throws when the port is taken. */ + fun start(port: Int, token: String, classLoader: ClassLoader) { + val server = embeddedServer(CIO, host = HOST, port = port) { + requireToken(token) + mcpStreamableHttp(path = PATH) { createSession() } + } + val thread = Thread.currentThread() + val previous = thread.contextClassLoader + thread.contextClassLoader = classLoader + try { + server.start(wait = false) + } finally { + thread.contextClassLoader = previous + } + engine = server + boundPort = port + } + + fun stop() { + engine?.stop(gracePeriodMillis = 200, timeoutMillis = 2_000) + engine = null + } + + private fun createSession(): Server = Server( + serverInfo = Implementation(name = "hollowengine", version = version, title = "HollowEngine"), + options = ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false))), + instructionsProvider = instructions, + ) { + toolset.forEach { tool -> + addTool( + name = tool.name, + description = tool.description, + inputSchema = tool.schema, + toolAnnotations = ToolAnnotations(readOnlyHint = tool.readOnly, destructiveHint = false), + ) { request -> runTool(tool, ToolArguments(request.arguments)) } + } + } + + private suspend fun runTool(tool: McpTool, arguments: ToolArguments) = try { + tool.handler(arguments) + } catch (error: CancellationException) { + throw error + } catch (error: ToolInputException) { + errorResult(error.message.orEmpty()) + } catch (error: Exception) { + HollowEngine.LOGGER.error("MCP tool '{}' failed", tool.name, error) + errorResult("${tool.name} failed: ${error.message ?: error::class.simpleName}") + } + + /** Answers 401 to any request without the token. The comparison takes the same time for any input. */ + private fun Application.requireToken(token: String) { + val expected = "Bearer $token".toByteArray() + intercept(ApplicationCallPipeline.Plugins) { + val presented = call.request.headers[HttpHeaders.Authorization]?.toByteArray() + if (presented == null || !MessageDigest.isEqual(presented, expected)) { + call.respondText("Missing or wrong bearer token", status = HttpStatusCode.Unauthorized) + finish() + } + } + } + + companion object { + const val HOST = "127.0.0.1" + const val PATH = "/mcp" + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpTool.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpTool.kt new file mode 100644 index 000000000..56960099f --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/McpTool.kt @@ -0,0 +1,87 @@ +package ru.hollowhorizon.hollowengine.addons.mcp + +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** A tool agents can call. [readOnly] tools only look at the game and never change it. */ +class McpTool( + val name: String, + val description: String, + val parameters: List, + val readOnly: Boolean, + val handler: suspend (ToolArguments) -> CallToolResult, +) { + val schema: ToolSchema + get() = ToolSchema( + properties = buildJsonObject { + parameters.forEach { parameter -> put(parameter.name, parameter.schema()) } + }, + required = parameters.filter(ToolParameter::required).map(ToolParameter::name), + ) +} + +enum class ParameterType(val jsonName: String) { STRING("string"), INTEGER("integer"), NUMBER("number"), BOOLEAN("boolean") } + +class ToolParameter( + val name: String, + val type: ParameterType, + val description: String, + val required: Boolean = false, + val allowed: List = emptyList(), +) { + fun schema(): JsonObject = buildJsonObject { + put("type", type.jsonName) + put("description", description) + if (allowed.isNotEmpty()) putJsonArray("enum") { allowed.forEach { add(JsonPrimitive(it)) } } + } +} + +/** Thrown for arguments the agent got wrong; it is answered as a tool error rather than logged. */ +class ToolInputException(message: String) : RuntimeException(message) + +class ToolArguments(private val json: JsonObject?) { + fun string(name: String): String = optionalString(name) ?: throw ToolInputException("'$name' is required") + + fun optionalString(name: String): String? = primitive(name)?.content?.takeIf { it.isNotEmpty() } + + fun int(name: String, default: Int): Int { + val value = primitive(name) ?: return default + return value.intOrNull ?: throw ToolInputException("'$name' must be an integer") + } + + fun optionalDouble(name: String): Double? { + val value = primitive(name) ?: return null + return value.doubleOrNull ?: throw ToolInputException("'$name' must be a number") + } + + fun boolean(name: String, default: Boolean): Boolean { + val value = primitive(name) ?: return default + return value.booleanOrNull ?: throw ToolInputException("'$name' must be true or false") + } + + private fun primitive(name: String): JsonPrimitive? = json?.get(name) as? JsonPrimitive +} + +fun textResult(text: String): CallToolResult = CallToolResult(content = listOf(TextContent(text))) + +fun errorResult(text: String): CallToolResult = CallToolResult(content = listOf(TextContent(text)), isError = true) + +fun contentResult(content: List): CallToolResult = CallToolResult(content = content) + +/** Cuts [text] to [limit] characters, saying how much was left out, so one answer cannot flood the agent. */ +fun truncate(text: String, limit: Int = OUTPUT_LIMIT): String { + if (text.length <= limit) return text + return text.take(limit) + "\n… ${text.length - limit} more characters cut" +} + +const val OUTPUT_LIMIT = 20_000 diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/Clipboard.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/Clipboard.kt new file mode 100644 index 000000000..6075704ea --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/Clipboard.kt @@ -0,0 +1,9 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.client + +import net.minecraft.client.Minecraft + +/** Puts [text] on the clipboard through this game's own window, on the render thread that owns it. */ +internal fun copyToClipboard(text: String) { + val minecraft = Minecraft.getInstance() + minecraft.execute { minecraft.keyboardHandler.clipboard = text } +} 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 new file mode 100644 index 000000000..dc55c3b9c --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/FrameCapture.kt @@ -0,0 +1,98 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.client + +import com.mojang.blaze3d.platform.NativeImage +import kotlinx.coroutines.CompletableDeferred +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.ui.screen.HollowWaitScreen +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher +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.RenderTickEvent +import kotlin.time.Duration.Companion.seconds + +/** + * Takes one frame out of the game's own render loop. A frame of the whole screen is read once it is + * finished; a frame of the world alone is read after the level is drawn and before the GUI is. + */ +@ClientOnly +object FrameCapture { + private class Request(val worldOnly: Boolean, val waitForSections: Boolean) { + val image = CompletableDeferred() + var frames = 0 + var settledFrames = 0 + val startedAt = System.currentTimeMillis() + } + + /** The capture being waited for. Touched only on the render thread. */ + private var pending: Request? = null + + /** + * Captures the next suitable frame; [pose] moves the camera there first and implies [worldOnly]. + * Null when no frame was drawn in time, as happens while the window is minimized. + */ + suspend fun capture(worldOnly: Boolean, pose: CameraPose?): NativeImage? { + val minecraft = Minecraft.getInstance() + return withContext(minecraft.dispatcher) { + check(pending == null) { "Another capture is in progress" } + if ((worldOnly || pose != null) && minecraft.level == null) error("No world is open, so there is nothing to capture but the menu") + val request = Request(worldOnly = worldOnly || pose != null, waitForSections = pose != null) + val previousScreen = minecraft.screen + val waitScreen = pose?.let { + HollowWaitScreen(WAIT_HEADING.lang, WAIT_MESSAGE.lang).also(minecraft::setScreen) + } + CutsceneCameraSystem.heldPose = pose + pending = request + try { + withTimeoutOrNull(CAPTURE_TIMEOUT) { request.image.await() } + } finally { + pending = null + CutsceneCameraSystem.heldPose = null + if (waitScreen != null && minecraft.screen === waitScreen) minecraft.setScreen(previousScreen) + } + } + } + + @SubscribeEvent + fun onLevelRendered(event: RenderTickEvent.LevelRendered) { + val request = pending?.takeIf { it.worldOnly } ?: return + request.frames++ + if (request.waitForSections && !sectionsSettled(event.minecraft, request)) return + complete(request, event.minecraft) + } + + /** A picture of the world alone leaves out the hand too, which is drawn with the level. */ + @SubscribeEvent + fun onRenderHand(event: RenderItemInHandEvent) { + if (pending?.worldOnly == true) event.isCanceled = true + } + + @SubscribeEvent + fun onBlit(event: RenderTickEvent.Blit) { + val request = pending?.takeIf { !it.worldOnly } ?: return + complete(request, event.minecraft) + } + + private fun sectionsSettled(minecraft: Minecraft, request: Request): Boolean { + if (System.currentTimeMillis() - request.startedAt >= SETTLE_LIMIT_MILLIS) return true + request.settledFrames = if (minecraft.levelRenderer.hasRenderedAllSections()) request.settledFrames + 1 else 0 + return request.frames >= MIN_SETTLE_FRAMES && request.settledFrames >= 2 + } + + private fun complete(request: Request, minecraft: Minecraft) { + pending = null + request.image.complete(Screenshot.takeScreenshot(minecraft.mainRenderTarget)) + } + + private const val WAIT_HEADING = "hollowengine_mcp.wait.heading" + private const val WAIT_MESSAGE = "hollowengine_mcp.wait.message" + private const val MIN_SETTLE_FRAMES = 8 + private const val SETTLE_LIMIT_MILLIS = 5_000L + private val CAPTURE_TIMEOUT = 10.seconds +} 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 new file mode 100644 index 000000000..0bd1c65f6 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/client/ScreenshotTool.kt @@ -0,0 +1,99 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.client + +import com.mojang.blaze3d.platform.NativeImage +import io.modelcontextprotocol.kotlin.sdk.types.ImageContent +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import net.minecraft.client.Minecraft +import ru.hollowhorizon.hollowengine.addons.mcp.McpTool +import ru.hollowhorizon.hollowengine.addons.mcp.ParameterType +import ru.hollowhorizon.hollowengine.addons.mcp.ToolArguments +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.common.utils.math.Vec3f +import java.util.Base64 +import java.util.Locale +import kotlin.math.atan2 +import kotlin.math.sqrt + +internal fun screenshotTool() = McpTool( + name = "screenshot", + description = """ + Takes a picture of the game. By default it is what the player sees, HUD and open screens + included; world_only leaves out the GUI and the hand. Given a camera position (x, y, z) it + shows the world from there instead, facing yaw/pitch or towards look_at_x/y/z: the player sees + a short wait screen meanwhile, and only what is loaded around the player can appear. Angles + are the F3 ones: yaw 0 faces south (+Z), 90 west, 180 north, -90 east; pitch -90 is up, 90 down. + A still frame cannot show whether an animation plays; check clip names with `he model entity` + instead. Pictures cost a lot of context, so take them only for what needs one. + """.trimIndent(), + parameters = listOf( + ToolParameter("world_only", ParameterType.BOOLEAN, "Leave out the HUD, any open screen and the hand"), + ToolParameter("x", ParameterType.NUMBER, "Camera position X"), + ToolParameter("y", ParameterType.NUMBER, "Camera position Y"), + ToolParameter("z", ParameterType.NUMBER, "Camera position Z"), + ToolParameter("yaw", ParameterType.NUMBER, "Camera yaw in degrees"), + ToolParameter("pitch", ParameterType.NUMBER, "Camera pitch in degrees"), + ToolParameter("look_at_x", ParameterType.NUMBER, "Point to face, instead of yaw and pitch"), + ToolParameter("look_at_y", ParameterType.NUMBER, "Point to face, instead of yaw and pitch"), + ToolParameter("look_at_z", ParameterType.NUMBER, "Point to face, instead of yaw and pitch"), + ToolParameter("fov", ParameterType.NUMBER, "Vertical field of view in degrees; the player's by default"), + ToolParameter("max_width", ParameterType.INTEGER, "Widest picture to return, $DEFAULT_WIDTH by default"), + ), + readOnly = true, +) { arguments -> + val pose = cameraPose(arguments) + val maxWidth = arguments.int("max_width", DEFAULT_WIDTH).coerceIn(MIN_WIDTH, MAX_WIDTH) + val image = FrameCapture.capture(arguments.boolean("world_only", false), pose) + ?: return@McpTool errorResult("No frame was drawn within 10 seconds; the game window may be minimized") + val encoded = image.use { encode(it, maxWidth) } + val caption = buildString { + append("${encoded.width}x${encoded.height}") + pose?.let { append(", camera at %.1f %.1f %.1f, yaw %.1f, pitch %.1f".format(Locale.ROOT, it.position.x, it.position.y, it.position.z, it.yaw, it.pitch)) } + } + contentResult(listOf(ImageContent(Base64.getEncoder().encodeToString(encoded.png), "image/png"), TextContent(caption))) +} + +private fun cameraPose(arguments: ToolArguments): CameraPose? { + val x = arguments.optionalDouble("x") + val y = arguments.optionalDouble("y") + val z = arguments.optionalDouble("z") + if (x == null && y == null && z == null) return null + if (x == null || y == null || z == null) throw ToolInputException("A camera position needs all of x, y and z") + + val lookAt = listOf("look_at_x", "look_at_y", "look_at_z").map(arguments::optionalDouble) + val (yaw, pitch) = if (lookAt.all { it != null }) { + val dx = lookAt[0]!! - x + val dy = lookAt[1]!! - y + val dz = lookAt[2]!! - z + Math.toDegrees(atan2(-dx, dz)) to Math.toDegrees(atan2(-dy, sqrt(dx * dx + dz * dz))) + } else { + (arguments.optionalDouble("yaw") ?: 0.0) to (arguments.optionalDouble("pitch") ?: 0.0) + } + val fov = arguments.optionalDouble("fov")?.toFloat() ?: Minecraft.getInstance().options.fov().get().toFloat() + return CameraPose( + position = Vec3f(x.toFloat(), y.toFloat(), z.toFloat()), + rotation = Vec3f(pitch.toFloat().coerceIn(-90f, 90f), yaw.toFloat(), 0f), + fov = fov.coerceIn(MIN_FOV, MAX_FOV), + ) +} + +private class EncodedImage(val png: ByteArray, val width: Int, val height: Int) + +/** Scales [image] down to [maxWidth] when it is wider, and writes it as PNG. */ +private fun encode(image: NativeImage, maxWidth: Int): EncodedImage { + if (image.width <= maxWidth) return EncodedImage(image.asByteArray(), image.width, image.height) + val height = (image.height.toLong() * maxWidth / image.width).toInt().coerceAtLeast(1) + return NativeImage(maxWidth, height, false).use { scaled -> + image.resizeSubRectTo(0, 0, image.width, image.height, scaled) + EncodedImage(scaled.asByteArray(), maxWidth, height) + } +} + +private const val DEFAULT_WIDTH = 1280 +private const val MIN_WIDTH = 64 +private const val MAX_WIDTH = 3840 +private const val MIN_FOV = 1f +private const val MAX_FOV = 170f diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/docs/DocsLibrary.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/docs/DocsLibrary.kt new file mode 100644 index 000000000..0ee7b2db4 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/docs/DocsLibrary.kt @@ -0,0 +1,137 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.docs + +/** + * The engine's English guides, shipped inside the addon. A page is addressed by its path without the + * extension ("scripting/node/states"); an `index` page also answers to its folder ("scripting/node"). + */ +class DocsLibrary(val pages: List) { + private val byId = pages.associateBy(DocsPage::id) + + fun page(id: String): DocsPage? { + val normalized = id.trim().removePrefix("/").removePrefix(SITE_PREFIX).removeSuffix(".mdx").trimEnd('/') + return byId[normalized] ?: byId["$normalized/index"] ?: byId[normalized.removeSuffix("/index")] + } + + /** Sections holding every word of [query], best first: a word in a heading weighs more than in text. */ + fun search(query: String, limit: Int): List { + val words = query.lowercase().split(WORD_SEPARATORS).filter(String::isNotBlank).distinct() + if (words.isEmpty()) return emptyList() + return pages.asSequence() + .flatMap { page -> page.sections.asSequence().map { section -> page to section } } + .mapNotNull { (page, section) -> + val heading = (page.title + " " + section.heading).lowercase() + val body = section.text.lowercase() + if (words.any { it !in heading && it !in body }) return@mapNotNull null + val score = words.sumOf { word -> (if (word in heading) HEADING_WEIGHT else 0) + body.occurrences(word).coerceAtMost(MAX_COUNTED) } + DocsHit(page, section, score, section.text.lines().firstOrNull { line -> words.any { it in line.lowercase() } }?.trim().orEmpty()) + } + .sortedByDescending(DocsHit::score) + .take(limit) + .toList() + } + + companion object { + /** How the pages link to each other on the site; stripped so a link reads as a page id. */ + const val SITE_PREFIX = "docs/hollowengine/" + + private val WORD_SEPARATORS = Regex("[^\\p{L}\\p{N}_.]+") + private const val HEADING_WEIGHT = 10 + private const val MAX_COUNTED = 5 + + private fun String.occurrences(word: String): Int { + var count = 0 + var from = indexOf(word) + while (from >= 0) { + count++ + from = indexOf(word, from + word.length) + } + return count + } + + /** Reads the pages listed in `pages.txt` next to them in the addon's resources. */ + fun load(classLoader: ClassLoader, root: String): DocsLibrary { + val listing = classLoader.getResourceAsStream("$root/pages.txt")?.bufferedReader()?.use { it.readLines() }.orEmpty() + val pages = listing.filter(String::isNotBlank).mapNotNull { path -> + val text = classLoader.getResourceAsStream("$root/$path")?.bufferedReader()?.use { it.readText() } + ?: return@mapNotNull null + DocsPage.parse(path.removeSuffix(".mdx"), text) + } + return DocsLibrary(pages) + } + } +} + +class DocsHit(val page: DocsPage, val section: DocsSection, val score: Int, val excerpt: String) + +/** One `#`-heading and the text under it, up to the next heading of any level. */ +class DocsSection(val heading: String, val anchor: String, val level: Int, val text: String) + +class DocsPage(val id: String, val title: String, val order: Int, val sections: List) { + val text: String get() = sections.joinToString("\n\n") { section -> section.render() } + + /** [anchor]'s section together with the deeper sections nested under it. */ + fun section(anchor: String): String? { + val start = sections.indexOfFirst { it.anchor == anchor.removePrefix("#") } + if (start < 0) return null + val level = sections[start].level + val end = (start + 1 until sections.size).firstOrNull { sections[it].level <= level } ?: sections.size + return sections.subList(start, end).joinToString("\n\n") { it.render() } + } + + private fun DocsSection.render(): String = + if (level == 0) text else "${"#".repeat(level)} $heading\n$text" + + companion object { + private val HEADING = Regex("^(#{1,6})\\s+(.+?)\\s*#*$") + private val IMAGE = Regex("^!\\[[^]]*]\\([^)]*\\)\\s*$") + private val SITE_LINK = Regex("]\\(/${Regex.escape(DocsLibrary.SITE_PREFIX)}") + + fun parse(id: String, source: String): DocsPage { + val (frontMatter, body) = splitFrontMatter(source.replace("\r\n", "\n")) + val sections = ArrayList() + var heading = "" + var level = 0 + val text = StringBuilder() + var inCode = false + + fun flush() { + val content = text.toString().trim() + if (level > 0 || content.isNotEmpty()) sections += DocsSection(heading, anchorOf(heading), level, content) + text.clear() + } + + for (line in body.lines()) { + if (line.trimStart().startsWith("```")) inCode = !inCode + val match = if (inCode) null else HEADING.matchEntire(line) + when { + match != null -> { + flush() + level = match.groupValues[1].length + heading = match.groupValues[2] + } + !inCode && (IMAGE.matches(line) || line.startsWith("import ") || line.startsWith("export ")) -> Unit + else -> text.appendLine(line.replace(SITE_LINK, "](")) + } + } + flush() + + val title = frontMatter["title"] ?: sections.firstOrNull { it.level == 1 }?.heading ?: id + return DocsPage(id, title, frontMatter["order"]?.toIntOrNull() ?: Int.MAX_VALUE, sections) + } + + private fun splitFrontMatter(source: String): Pair, String> { + if (!source.startsWith("---\n")) return emptyMap() to source + val end = source.indexOf("\n---", startIndex = 4) + if (end < 0) return emptyMap() to source + val values = source.substring(4, end).lines().mapNotNull { line -> + val key = line.substringBefore(':', "").trim() + if (key.isEmpty()) null else key to line.substringAfter(':').trim().trim('"', '\'') + }.toMap() + return values to source.substring(end + 4).trimStart('\n') + } + + /** The anchor a heading gets on the site: lowercase, spaces to dashes, punctuation dropped. */ + fun anchorOf(heading: String): String = + heading.lowercase().replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(Regex("\\s+"), "-") + } +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/CodeTools.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/CodeTools.kt new file mode 100644 index 000000000..b379a43b8 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/CodeTools.kt @@ -0,0 +1,151 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.hollowhorizon.hollowengine.addons.mcp.COMPILER_MISSING +import ru.hollowhorizon.hollowengine.addons.mcp.McpTool +import ru.hollowhorizon.hollowengine.addons.mcp.ParameterType +import ru.hollowhorizon.hollowengine.addons.mcp.ToolInputException +import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter +import ru.hollowhorizon.hollowengine.addons.mcp.errorResult +import ru.hollowhorizon.hollowengine.addons.mcp.textResult +import ru.hollowhorizon.hollowengine.addons.mcp.truncate +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptingAnalyzer +import ru.hollowhorizon.hollowengine.common.scripting.ide.Severity +import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry +import java.io.File + +internal fun codeTools(): List = listOf(scriptDiagnosticsTool(), symbolSearchTool(), symbolSourceTool()) + +private fun scriptDiagnosticsTool() = McpTool( + name = "script_diagnostics", + description = """ + Errors and warnings the in-game Kotlin analyzer reports for a script, against the classpath the + game really compiles it with (Minecraft in Mojang names, loaded mods, addons). It compiles and + runs nothing, so it is the quick check after editing a script. Pass `text` to check a version + that is not saved yet. Positions are 1-based line:column. + """.trimIndent(), + parameters = listOf( + ToolParameter( + "path", ParameterType.STRING, + "Script path as commands spell it: 'scripts/folder/name.node.kts' for the project's own scripts " + + "(the hollowengine/scripts folder), 'namespace:path' for an addon's", + required = true, + ), + ToolParameter("text", ParameterType.STRING, "Script source to check instead of the saved file"), + ), + readOnly = true, +) { arguments -> + val analyzer = analyzerOrNull() ?: return@McpTool errorResult(COMPILER_MISSING) + val path = arguments.string("path") + if (!path.endsWith(".kts")) throw ToolInputException("Only Kotlin scripts (.kts) are analyzed") + val display = ScriptRegistry.display(ScriptRegistry.parse(path)) + val text = arguments.optionalString("text") ?: readScript(path) + val diagnostics = withContext(Dispatchers.IO) { analyzer.diagnostic(display, text) } + .filter { it.severity >= Severity.WARNING } + textResult(truncate(describe(display, text, diagnostics))) +} + +private fun symbolSearchTool() = McpTool( + name = "symbol_search", + description = """ + Finds classes and Kotlin top-level functions and properties (extension functions included) on the + script classpath by name: Minecraft in Mojang names, the engine's scripting API, mods and addons. + The query is a simple name or part of one, optionally qualified by part of its package + ('entity.Entity', 'npcs.spawn'). Closest matches come first. Read one with symbol_source. + """.trimIndent(), + parameters = listOf( + ToolParameter("query", ParameterType.STRING, "Name or part of a name", required = true), + ToolParameter("limit", ParameterType.INTEGER, "Most results to return, $DEFAULT_SEARCH_LIMIT by default"), + ), + readOnly = true, +) { arguments -> + val analyzer = analyzerOrNull() ?: return@McpTool errorResult(COMPILER_MISSING) + val query = arguments.string("query") + val limit = arguments.int("limit", DEFAULT_SEARCH_LIMIT).coerceIn(1, MAX_SEARCH_LIMIT) + val matches = withContext(Dispatchers.IO) { analyzer.searchSymbols(query, limit) } + if (matches.isEmpty()) return@McpTool textResult("Nothing on the script classpath matches '$query'") + textResult(matches.joinToString("\n") { match -> + val kind = match.kind.name.lowercase() + if (match.signature.isEmpty()) "$kind ${match.qualifiedName}" else "$kind ${match.qualifiedName}: ${match.signature}" + }) +} + +private fun symbolSourceTool() = McpTool( + name = "symbol_source", + description = """ + Source of a class, member or top-level declaration by qualified name, from the library's sources jar + when there is one and decompiled otherwise (Minecraft decompiles in Mojang names). Nested classes + are spelled Outer.Inner, members Class.member; overloads show the first one. Long files come a + window at a time: by default the lines around the declaration, and `line`/`lines` page further. + """.trimIndent(), + parameters = listOf( + ToolParameter("name", ParameterType.STRING, "Qualified name, e.g. net.minecraft.world.entity.Entity.tick", required = true), + ToolParameter("line", ParameterType.INTEGER, "First line to show, 1-based"), + ToolParameter("lines", ParameterType.INTEGER, "How many lines to show, $DEFAULT_SOURCE_LINES by default"), + ), + readOnly = true, +) { arguments -> + val analyzer = analyzerOrNull() ?: return@McpTool errorResult(COMPILER_MISSING) + val name = arguments.string("name") + val location = withContext(Dispatchers.IO) { analyzer.symbolSource(name) } + ?: return@McpTool errorResult( + "Nothing on the script classpath is named '$name'. Find the exact name with symbol_search; " + + "nested classes are spelled Outer.Inner" + ) + val text = location.text + ?: return@McpTool textResult("'$name' is declared in the project's own file ${location.path}; read it there") + + val lines = text.lines() + val declarationLine = text.substring(0, location.offset.coerceIn(0, text.length)).count { it == '\n' } + 1 + val count = arguments.int("lines", DEFAULT_SOURCE_LINES).coerceIn(1, MAX_SOURCE_LINES) + val first = arguments.int("line", (declarationLine - LINES_BEFORE_DECLARATION).coerceAtLeast(1)).coerceIn(1, lines.size) + val last = (first + count - 1).coerceAtMost(lines.size) + val origin = if (location.path.startsWith("decompiled/")) "decompiled" else "source" + val body = (first..last).joinToString("\n") { number -> "%6d %s".format(number, lines[number - 1]) } + textResult( + truncate( + "// ${location.path} ($origin), lines $first-$last of ${lines.size}; '$name' is at line $declarationLine\n$body", + limit = MAX_SOURCE_CHARACTERS, + ) + ) +} + +private fun analyzerOrNull(): ScriptingAnalyzer? = ScriptingEnvironment.currentOrNull()?.analyzer + +private fun readScript(path: String): String { + val file = ScriptRegistry.artifacts(path)?.sourceFile?.takeIf(File::isFile) + ?: throw ToolInputException("There is no script source at '$path'") + return file.readText() +} + +private fun describe(path: String, text: String, diagnostics: List): String { + val errors = diagnostics.count { it.severity.isError() } + val warnings = diagnostics.size - errors + if (diagnostics.isEmpty()) return "$path: no errors or warnings" + val lines = text.lines() + return buildString { + appendLine("$path: $errors errors, $warnings warnings") + diagnostics.sortedWith(compareBy({ it.range.start.line }, { it.range.start.column })).take(MAX_DIAGNOSTICS).forEach { diagnostic -> + val start = diagnostic.range.start + val severity = if (diagnostic.severity.isError()) "error" else "warning" + if (start.line < 0) { + appendLine("$severity: ${diagnostic.message}") + return@forEach + } + appendLine("$severity ${start.line + 1}:${start.column + 1}: ${diagnostic.message}") + lines.getOrNull(start.line)?.let { source -> appendLine(" | ${source.trimEnd()}") } + } + if (diagnostics.size > MAX_DIAGNOSTICS) appendLine("… ${diagnostics.size - MAX_DIAGNOSTICS} more") + }.trimEnd() +} + +private const val DEFAULT_SEARCH_LIMIT = 30 +private const val MAX_SEARCH_LIMIT = 200 +private const val DEFAULT_SOURCE_LINES = 200 +private const val MAX_SOURCE_LINES = 1_000 +private const val MAX_SOURCE_CHARACTERS = 80_000 +private const val LINES_BEFORE_DECLARATION = 10 +private const val MAX_DIAGNOSTICS = 100 diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/CommandTool.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/CommandTool.kt new file mode 100644 index 000000000..4952478d5 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/CommandTool.kt @@ -0,0 +1,90 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.tools + +import com.mojang.brigadier.ParseResults +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import kotlinx.coroutines.withContext +import net.minecraft.commands.CommandResultCallback +import net.minecraft.commands.CommandSource +import net.minecraft.commands.CommandSourceStack +import net.minecraft.network.chat.Component +import net.minecraft.server.MinecraftServer +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.addons.mcp.McpTool +import ru.hollowhorizon.hollowengine.addons.mcp.ParameterType +import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter +import ru.hollowhorizon.hollowengine.addons.mcp.errorResult +import ru.hollowhorizon.hollowengine.addons.mcp.textResult +import ru.hollowhorizon.hollowengine.addons.mcp.truncate +import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher + +internal fun runCommandTool() = McpTool( + name = "run_command", + description = """ + Runs a command on this game's server and returns the feedback it printed, which would otherwise go + to chat. In a singleplayer or LAN world it runs as the host player, with the host's rights and + position; on a dedicated server, as the server console. + HollowEngine's own commands live under /hollowengine (alias /he), e.g. `he scripting compile`, + `he scripting run `, `reload`. `help hollowengine` lists them; a command that does not + parse is answered with the usage of the part that did. + """.trimIndent(), + parameters = listOf( + ToolParameter("command", ParameterType.STRING, "The command, with or without the leading slash", required = true), + ), + readOnly = false, +) { arguments -> + val server = ServerAccess.current() ?: return@McpTool errorResult(ServerAccess.NO_SERVER) + withContext(server.dispatcher) { runCommand(server, arguments.string("command").trim().removePrefix("/")) } +} + +private fun runCommand(server: MinecraftServer, command: String): CallToolResult { + val source = if (server.isDedicatedServer) server.createCommandSourceStack() + else ServerAccess.host(server)?.createCommandSourceStack() + ?: return errorResult("The host player has not joined the world yet") + + val output = CapturedFeedback() + var outcome: Pair? = null + val stack = source.withSource(output).withCallback(CommandResultCallback { success, result -> outcome = success to result }) + val dispatcher = server.commands.dispatcher + val parse = dispatcher.parse(command, stack) + HollowEngine.LOGGER.info("MCP agent runs /{}", command) + server.commands.performCommand(parse, command) + + val failedToParse = parse.reader.canRead() || parse.exceptions.isNotEmpty() + val report = buildString { + output.lines.forEach(::appendLine) + outcome?.let { (success, result) -> appendLine(if (success) "(result: $result)" else "(the command failed)") } + if (output.lines.isEmpty() && outcome == null && !failedToParse) appendLine("(no feedback)") + if (failedToParse) usage(parse, stack)?.let { usage -> + appendLine("Usage:") + usage.forEach { appendLine(" /$it") } + } + }.trimEnd() + val failed = failedToParse || outcome?.first == false + return if (failed) errorResult(truncate(report)) else textResult(truncate(report)) +} + +/** What can follow the longest part of [parse] that did parse, or null when not even the command name did. */ +private fun usage(parse: ParseResults, stack: CommandSourceStack): List? { + val context = runCatching { parse.context.findSuggestionContext(parse.reader.cursor) }.getOrNull() ?: return null + val dispatcher = stack.server.commands.dispatcher + if (context.parent === dispatcher.root) return null + val prefix = parse.reader.string.substring(0, context.startPos).trimEnd() + return dispatcher.getSmartUsage(context.parent, stack).values.take(MAX_USAGE_LINES).map { "$prefix $it" } +} + +/** A command source that keeps the feedback a command sends instead of showing it to anyone. */ +private class CapturedFeedback : CommandSource { + val lines = ArrayList() + + override fun sendSystemMessage(component: Component) { + lines += component.string + } + + override fun acceptsSuccess(): Boolean = true + + override fun acceptsFailure(): Boolean = true + + override fun shouldInformAdmins(): Boolean = false +} + +private const val MAX_USAGE_LINES = 30 diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/DocsTools.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/DocsTools.kt new file mode 100644 index 000000000..6ff5d2d3d --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/DocsTools.kt @@ -0,0 +1,68 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.tools + +import ru.hollowhorizon.hollowengine.addons.mcp.McpTool +import ru.hollowhorizon.hollowengine.addons.mcp.ParameterType +import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter +import ru.hollowhorizon.hollowengine.addons.mcp.docs.DocsLibrary +import ru.hollowhorizon.hollowengine.addons.mcp.docs.DocsPage +import ru.hollowhorizon.hollowengine.addons.mcp.errorResult +import ru.hollowhorizon.hollowengine.addons.mcp.textResult +import ru.hollowhorizon.hollowengine.addons.mcp.truncate + +internal fun docsTools(docs: DocsLibrary): List = listOf(docsSearchTool(docs), docsReadTool(docs)) + +private fun docsSearchTool(docs: DocsLibrary) = McpTool( + name = "docs_search", + description = """ + Searches HollowEngine's guides (script types, node scripts, NPCs, dialogues, UI, models, + animations, cutscenes, the IDE…) for sections holding every word of the query. Answers + page#section ids to open with docs_read. + """.trimIndent(), + parameters = listOf( + ToolParameter("query", ParameterType.STRING, "Words to look for", required = true), + ToolParameter("limit", ParameterType.INTEGER, "Most sections to return, $DEFAULT_HITS by default"), + ), + readOnly = true, +) { arguments -> + val query = arguments.string("query") + val hits = docs.search(query, arguments.int("limit", DEFAULT_HITS).coerceIn(1, MAX_HITS)) + if (hits.isEmpty()) return@McpTool textResult("No section mentions every word of '$query'. Try fewer or other words, or docs_read without a page for the contents") + textResult(hits.joinToString("\n") { hit -> + val place = if (hit.section.anchor.isEmpty()) hit.page.id else "${hit.page.id}#${hit.section.anchor}" + val heading = listOf(hit.page.title, hit.section.heading).filter(String::isNotBlank).distinct().joinToString(" › ") + "$place — $heading: ${hit.excerpt.take(EXCERPT_LENGTH)}" + }) +} + +private fun docsReadTool(docs: DocsLibrary) = McpTool( + name = "docs_read", + description = """ + Reads a HollowEngine guide page, or one section of it with its subsections. Without a page it + lists every page. Links inside pages point at other page ids. + """.trimIndent(), + parameters = listOf( + ToolParameter("page", ParameterType.STRING, "Page id such as 'scripting/node/states'; may carry '#section'"), + ToolParameter("section", ParameterType.STRING, "Section anchor, as docs_search gives it after '#'"), + ), + readOnly = true, +) { arguments -> + val requested = arguments.optionalString("page") ?: return@McpTool textResult(contents(docs)) + val page = docs.page(requested.substringBefore('#')) + ?: return@McpTool errorResult("There is no page '$requested'. docs_read without a page lists them") + val anchor = arguments.optionalString("section") ?: requested.substringAfter('#', "").takeIf(String::isNotEmpty) + val text = if (anchor == null) page.text else page.section(anchor) + ?: return@McpTool errorResult("'${page.id}' has no section '$anchor'. Its sections: ${page.sections.map { it.anchor }.filter(String::isNotEmpty).joinToString()}") + textResult(truncate("# ${page.title} (${page.id})\n\n$text", limit = MAX_PAGE_CHARACTERS)) +} + +private fun contents(docs: DocsLibrary): String { + if (docs.pages.isEmpty()) return "This build of the addon carries no guides" + return docs.pages + .sortedWith(compareBy { it.id.substringBeforeLast('/', "") }.thenBy { it.order }.thenBy { it.id }) + .joinToString("\n") { page -> "${page.id} — ${page.title}" } +} + +private const val DEFAULT_HITS = 10 +private const val MAX_HITS = 50 +private const val EXCERPT_LENGTH = 160 +private const val MAX_PAGE_CHARACTERS = 60_000 diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/ServerAccess.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/ServerAccess.kt new file mode 100644 index 000000000..1b1c794df --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/ServerAccess.kt @@ -0,0 +1,27 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.tools + +import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerPlayer +import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull + +/** + * The logical server of this game process and what the agent may do on it. A dedicated server trusts + * the agent like its own console; in a singleplayer or LAN world the agent has the host's rights. + */ +internal object ServerAccess { + const val NO_SERVER = "No world is open in this game process, so there is no server to act on. " + + "A client connected to a remote server cannot reach that server's commands or code from here." + + const val NO_CHEATS = "The host of this world has no operator rights (cheats are off), so server code " + + "cannot run here. Enable cheats (Open to LAN → Allow Commands) or use the client side." + + fun current(): MinecraftServer? = currentServerOrNull()?.takeIf(MinecraftServer::isRunning) + + /** The player hosting a singleplayer or LAN world. Call on the server thread. */ + fun host(server: MinecraftServer): ServerPlayer? = + server.playerList.players.firstOrNull { server.isSingleplayerOwner(it.gameProfile) } + + /** Whether server snippets may run on [server]. Call on the server thread. */ + fun mayRunCode(server: MinecraftServer): Boolean = + server.isDedicatedServer || host(server)?.hasPermissions(2) == true +} diff --git a/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/SnippetTool.kt b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/SnippetTool.kt new file mode 100644 index 000000000..6b0a16cb8 --- /dev/null +++ b/addons/mcp/src/main/java/ru/hollowhorizon/hollowengine/addons/mcp/tools/SnippetTool.kt @@ -0,0 +1,100 @@ +package ru.hollowhorizon.hollowengine.addons.mcp.tools + +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import kotlinx.coroutines.withContext +import ru.hollowhorizon.hollowengine.addons.mcp.COMPILER_MISSING +import ru.hollowhorizon.hollowengine.addons.mcp.McpTool +import ru.hollowhorizon.hollowengine.addons.mcp.ParameterType +import ru.hollowhorizon.hollowengine.addons.mcp.ToolInputException +import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter +import ru.hollowhorizon.hollowengine.addons.mcp.errorResult +import ru.hollowhorizon.hollowengine.addons.mcp.textResult +import ru.hollowhorizon.hollowengine.addons.mcp.truncate +import ru.hollowhorizon.hollowengine.client.scripting.ConsoleScripts +import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.console.ServerConsoleScripts +import ru.hollowhorizon.hollowengine.common.scripting.console.SnippetRun +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptCompilationException +import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient + +internal fun runSnippetTool() = McpTool( + name = "run_snippet", + description = """ + Compiles and runs a Kotlin snippet inside the game, like the IDE console, and returns the lines it + printed with println() and the value of its last expression. Use it to inspect live game state or + to try an API before writing it into a script. It is not how features are built: what a snippet + does is gone after a restart, so behavior the user asked for goes into a script file. + side=server runs on the server thread with `server`, `overworld`, `players` and `player` (the + host, or the only player) in scope. It needs an open world where the agent may run code: a + dedicated server, or a local world whose host has cheats on. + side=client runs on the render thread with `minecraft`, `player`, `level` and `server` (the + integrated one, touch it via server?.execute { }) in scope; it also works in the main menu. + Without side: server when allowed, client otherwise. Coroutines started with launch { } keep + running after the snippet returns; what they print only reaches the game log. + Common Minecraft types are imported already; add other imports as lines at the top. + """.trimIndent(), + parameters = listOf( + ToolParameter("code", ParameterType.STRING, "Kotlin statements; the last expression is the result", required = true), + ToolParameter("side", ParameterType.STRING, "Where to run it", allowed = listOf(SERVER, CLIENT)), + ), + readOnly = false, +) { arguments -> + val code = arguments.string("code") + if (!ScriptingEnvironment.isAvailable()) return@McpTool errorResult(COMPILER_MISSING) + when (resolveSide(arguments.optionalString("side"))) { + SERVER -> { + val server = ServerAccess.current() ?: return@McpTool errorResult(ServerAccess.NO_SERVER) + report(SERVER, ServerConsoleScripts.evaluate(server, code)) + } + else -> report(CLIENT, ConsoleScripts.evaluate(code)) + } +} + +private suspend fun resolveSide(requested: String?): String { + val server = ServerAccess.current() + val serverAllowed = server != null && withContext(server.dispatcher) { ServerAccess.mayRunCode(server) } + return when (requested) { + SERVER -> when { + server == null -> throw ToolInputException(ServerAccess.NO_SERVER) + !serverAllowed -> throw ToolInputException(ServerAccess.NO_CHEATS) + else -> SERVER + } + CLIENT -> if (isPhysicalClient) CLIENT else throw ToolInputException("This is a dedicated server; it has no client side") + null -> if (serverAllowed || !isPhysicalClient) SERVER else CLIENT + else -> throw ToolInputException("'side' must be '$SERVER' or '$CLIENT'") + } +} + +private fun report(side: String, run: SnippetRun): CallToolResult { + val error = run.result.exceptionOrNull() + val text = buildString { + appendLine("Ran on the $side.") + if (run.output.isNotEmpty()) { + appendLine("Printed:") + run.output.forEach(::appendLine) + } + run.result.onSuccess { result -> + if (result.hasValue) appendLine("Result: ${runCatching { result.value.toString() }.getOrElse { "" }}") + } + when (error) { + null -> Unit + is ScriptCompilationException -> { + appendLine("Did not compile:") + error.reports.filter { it.severity.isError() }.forEach { report -> + val start = report.range.start + appendLine(if (start.line < 0) report.message else "${start.line + 1}:${start.column + 1}: ${report.message}") + } + } + else -> { + appendLine("Threw:") + append(error.stackTraceToString().lineSequence().take(MAX_TRACE_LINES).joinToString("\n")) + } + } + }.trimEnd() + return if (error == null) textResult(truncate(text)) else errorResult(truncate(text)) +} + +private const val SERVER = "server" +private const val CLIENT = "client" +private const val MAX_TRACE_LINES = 40 diff --git a/addons/mcp/src/main/resources/META-INF/plugin.properties b/addons/mcp/src/main/resources/META-INF/plugin.properties new file mode 100644 index 000000000..3b5e362ba --- /dev/null +++ b/addons/mcp/src/main/resources/META-INF/plugin.properties @@ -0,0 +1,7 @@ +id=hollowengine-mcp +name=HollowEngine MCP +version=${version} +entry=ru.hollowhorizon.hollowengine.addons.mcp.HollowMcpAddon +environment=common +description=Model Context Protocol server that lets coding agents work with scripts inside the running game +authors=HollowHorizon diff --git a/addons/mcp/src/main/resources/assets/hollowengine-mcp/lang/en_us.json b/addons/mcp/src/main/resources/assets/hollowengine-mcp/lang/en_us.json new file mode 100644 index 000000000..fbccd0761 --- /dev/null +++ b/addons/mcp/src/main/resources/assets/hollowengine-mcp/lang/en_us.json @@ -0,0 +1,18 @@ +{ + "hollowengine_mcp.wait.heading": "An agent is looking at the world", + "hollowengine_mcp.wait.message": "The camera comes back in a moment", + "hollowengine_mcp.command.running": "running", + "hollowengine_mcp.command.stopped": "stopped", + "hollowengine_mcp.command.copy": "Copy the connection for your agent:", + "hollowengine_mcp.command.elsewhere": "Agents connect on the machine this game runs on, with the token from config/hollowengine-mcp.toml", + "hollowengine_mcp.command.host_only": "Only the host of a local world can copy the connection: agents reach it on this computer alone", + "hollowengine_mcp.command.copied": "Copied the connection for %s", + "hollowengine_mcp.command.hint_token": "Carries the access token", + "hollowengine_mcp.command.hint.claude": "A terminal command that adds this server to Claude Code", + "hollowengine_mcp.command.hint.codex": "A block for ~/.codex/config.toml", + "hollowengine_mcp.command.hint.json": "An mcpServers entry for Cursor and other clients that read one", + "hollowengine_mcp.command.hint.token": "The access token alone", + "hollowengine_mcp.command.status.starting": "The MCP server has not started yet", + "hollowengine_mcp.command.status.disabled": "The server is off: set enabled = true in config/hollowengine-mcp.toml and restart the game", + "hollowengine_mcp.command.status.port_taken": "Port %s is taken; pick another in config/hollowengine-mcp.toml and restart the game" +} diff --git a/addons/mcp/src/main/resources/assets/hollowengine-mcp/lang/ru_ru.json b/addons/mcp/src/main/resources/assets/hollowengine-mcp/lang/ru_ru.json new file mode 100644 index 000000000..bce9b0296 --- /dev/null +++ b/addons/mcp/src/main/resources/assets/hollowengine-mcp/lang/ru_ru.json @@ -0,0 +1,18 @@ +{ + "hollowengine_mcp.wait.heading": "Агент осматривает мир", + "hollowengine_mcp.wait.message": "Камера вернётся через мгновение", + "hollowengine_mcp.command.running": "работает", + "hollowengine_mcp.command.stopped": "остановлен", + "hollowengine_mcp.command.copy": "Скопировать подключение для агента:", + "hollowengine_mcp.command.elsewhere": "Агенты подключаются на той машине, где запущена игра, с токеном из config/hollowengine-mcp.toml", + "hollowengine_mcp.command.host_only": "Скопировать подключение может только хост локального мира: агенты подключаются только на этом компьютере", + "hollowengine_mcp.command.copied": "Подключение для %s скопировано", + "hollowengine_mcp.command.hint_token": "Содержит токен доступа", + "hollowengine_mcp.command.hint.claude": "Команда для терминала, добавляющая сервер в Claude Code", + "hollowengine_mcp.command.hint.codex": "Блок для ~/.codex/config.toml", + "hollowengine_mcp.command.hint.json": "Запись mcpServers для Cursor и других клиентов", + "hollowengine_mcp.command.hint.token": "Только токен доступа", + "hollowengine_mcp.command.status.starting": "MCP-сервер ещё не запущен", + "hollowengine_mcp.command.status.disabled": "Сервер выключен: поставьте enabled = true в config/hollowengine-mcp.toml и перезапустите игру", + "hollowengine_mcp.command.status.port_taken": "Порт %s занят: выберите другой в config/hollowengine-mcp.toml и перезапустите игру" +} diff --git a/addons/mcp/src/main/resources/hollowengine-mcp/instructions.md b/addons/mcp/src/main/resources/hollowengine-mcp/instructions.md new file mode 100644 index 000000000..eb882a809 --- /dev/null +++ b/addons/mcp/src/main/resources/hollowengine-mcp/instructions.md @@ -0,0 +1,50 @@ +HollowEngine is a Minecraft 1.21.1 mod whose content is written as Kotlin scripts. These tools act inside the running game this server belongs to. + +## Build features as script files, not snippets + +Whatever the user asks the game to do (a behavior, a reaction, an NPC, an item, a screen) belongs in a script file under {scripts}. A file survives restarts, the user can read and change it, and it is what they asked for. `run_snippet` is for looking at live state and trying an API out; what it does is gone after a restart. Never deliver a feature as a snippet, and do not leave coroutines from snippets running in the background. + +Pick the script type first (`docs_read scripting` compares them): +- Behavior of one entity, such as an NPC reacting to players: a node script `*.node.kts` with `@file:Attach()`, attached with `he scripting attach `. It is saved together with the entity. Read `scripting/node`, `scripting/node/editor` and `scripting/npc/actions`. Its shape: + +```kotlin +@file:Attach(NpcEntity::class) + +import ru.hollowhorizon.hollowengine.common.entities.NpcEntity + +val approachDistance by editor.property("Approach distance", min = 1.0, max = 12.0) { 4 } +val greeting by editor.property("Greeting animation") { "hello" } +var greeted = false + +onUpdate(every = 5.ticks) { + val npc = this@NpcEntity + val player = npc.level().players().filter { it.isAlive }.minByOrNull { it.distanceTo(npc) } ?: return@onUpdate + val distance = npc.distanceTo(player) + if (distance <= approachDistance && !greeted) { + npc.startLookingAt(player.position()) + npc.startAnimation(greeting) + greeted = true + } else if (distance >= approachDistance * 2) { + greeted = false + } +} +``` +- Logic for the whole world: a server node started with `he scripting run `, or event handlers in a `*.reload.kts`, which `reload` restarts. +- Items, blocks and anything else Minecraft only accepts at startup: `*.startup.kts`, applied after a game restart. +- A one-off action, like placing an NPC for a scene: a plain `*.kts` run with `he scripting run `, or a command. + +## Work in this loop + +1. Read the guide page for the area (`docs_search`, `docs_read`) and look the API up with `symbol_search` and `symbol_source` instead of guessing: the engine's API is mostly extension functions, and its parameters with defaults show `= …`. +2. Write the file on disk. Script paths are spelled `scripts/...` by commands and tools; addons' scripts are `namespace:path`. +3. Run `script_diagnostics` until it reports no errors. +4. Start it through `run_command` (`he scripting run`, `he scripting attach`, `reload`). Errors a script throws while running are written to the game log, {log}; read it from disk. +5. Check the result through game state: `he scripting list` and `he scripting list entity ` for running nodes, `run_snippet` for positions, components and values. + +## Assets are data: check their names + +Animation clips, materials and bones are named by each model; there is no clip every model has, and the guides' examples use made-up names such as "wave". Before a script plays a clip, list the real ones: `he model entity ` for the model an entity shows, `he model info ` for a model file. A clip the model lacks plays as nothing, and the game log says so. `he model animation play ` tries one out on an entity. + +`screenshot` cannot tell whether an animation plays, as a still frame shows no motion, and every picture costs a lot of context. Use it for what only a picture shows: placement, layout, lighting, materials, UI, and take as few as possible. + +Entities are picked with selectors, e.g. `@e[type=hollowengine:npc_entity,sort=nearest,limit=1]`. diff --git a/addons/mcp/src/test/kotlin/DocsLibraryTest.kt b/addons/mcp/src/test/kotlin/DocsLibraryTest.kt new file mode 100644 index 000000000..d029c8f49 --- /dev/null +++ b/addons/mcp/src/test/kotlin/DocsLibraryTest.kt @@ -0,0 +1,69 @@ +import ru.hollowhorizon.hollowengine.addons.mcp.docs.DocsLibrary +import ru.hollowhorizon.hollowengine.addons.mcp.docs.DocsPage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DocsLibraryTest { + private val states = DocsPage.parse( + "scripting/node/states", + """ + --- + title: States + order: 3 + --- + + Intro about node states. + ![A picture](/docs/state.webp) + + ## Switching states + Call `state("next")` to switch. See [events](/docs/hollowengine/scripting/events). + + ### Timeouts + A state may time out. + + ```kotlin + # not a heading inside code + ``` + + ## Saving + States are saved with the world. + """.trimIndent(), + ) + private val library = DocsLibrary(listOf(states, DocsPage.parse("scripting/node/index", "# Nodes\nNode scripts run handlers."))) + + @Test + fun `sections split at headings but not inside code`() { + assertEquals("States", states.title) + assertEquals(listOf("", "switching-states", "timeouts", "saving"), states.sections.map { it.anchor }) + assertTrue("# not a heading inside code" in states.section("timeouts")!!) + } + + @Test + fun `a section includes the deeper ones under it and stops at its own level`() { + val section = assertNotNull(states.section("switching-states")) + assertTrue("A state may time out." in section) + assertFalse("saved with the world" in section) + } + + @Test + fun `site links become page ids and images are dropped`() { + assertTrue("[events](scripting/events)" in states.text) + assertFalse("state.webp" in states.text) + } + + @Test + fun `folders and site paths resolve to their pages`() { + assertEquals("scripting/node/index", library.page("scripting/node")?.id) + assertEquals("scripting/node/states", library.page("/docs/hollowengine/scripting/node/states")?.id) + } + + @Test + fun `search needs every word and ranks headings first`() { + assertEquals(listOf("timeouts"), library.search("state time out", 10).map { it.section.anchor }) + assertEquals("saving", library.search("saving", 10).first().section.anchor) + assertTrue(library.search("nonexistent words", 10).isEmpty()) + } +} diff --git a/addons/mcp/src/test/kotlin/McpServerTest.kt b/addons/mcp/src/test/kotlin/McpServerTest.kt new file mode 100644 index 000000000..61de9cb46 --- /dev/null +++ b/addons/mcp/src/test/kotlin/McpServerTest.kt @@ -0,0 +1,83 @@ +import ru.hollowhorizon.hollowengine.addons.mcp.McpServer +import ru.hollowhorizon.hollowengine.addons.mcp.McpTool +import ru.hollowhorizon.hollowengine.addons.mcp.ParameterType +import ru.hollowhorizon.hollowengine.addons.mcp.ToolParameter +import ru.hollowhorizon.hollowengine.addons.mcp.textResult +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class McpServerTest { + private val echo = McpTool( + name = "echo", + description = "Repeats its text", + parameters = listOf(ToolParameter("text", ParameterType.STRING, "What to repeat", required = true)), + readOnly = true, + ) { arguments -> textResult("echo: ${arguments.string("text")}") } + + private val server = McpServer("test", listOf(echo)) { "instructions for the agent" } + private val client = HttpClient.newHttpClient() + private val port = ServerSocket(0).use { it.localPort } + + @BeforeTest + fun start() = server.start(port, TOKEN, javaClass.classLoader) + + @AfterTest + fun stop() = server.stop() + + @Test + fun `a request without the token is refused`() { + assertEquals(401, post(INITIALIZE, token = null).statusCode()) + assertEquals(401, post(INITIALIZE, token = "wrong").statusCode()) + } + + @Test + fun `a session lists and calls the tools`() { + val initialized = post(INITIALIZE) + assertEquals(200, initialized.statusCode(), initialized.body()) + assertContains(initialized.body(), "instructions for the agent") + val session = assertNotNull(initialized.headers().firstValue("mcp-session-id").orElse(null)) + + post("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", session = session) + val listed = post("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}""", session = session).body() + assertContains(listed, "\"echo\"") + assertContains(listed, "\"readOnlyHint\":true") + + val called = post( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}""", + session = session, + ).body() + assertContains(called, "echo: hi") + + val missing = post( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":{}}}""", + session = session, + ).body() + assertContains(missing, "'text' is required") + assertContains(missing, "\"isError\":true") + } + + private fun post(body: String, token: String? = TOKEN, session: String? = null): HttpResponse { + val request = HttpRequest.newBuilder(URI("http://127.0.0.1:$port/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .apply { if (token != null) header("Authorization", "Bearer $token") } + .apply { if (session != null) header("mcp-session-id", session) } + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + return client.send(request, HttpResponse.BodyHandlers.ofString()) + } + + private companion object { + const val TOKEN = "secret" + const val INITIALIZE = """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}""" + } +} diff --git a/addons/video/src/main/java/ru/hollowhorizon/hollowengine/addons/video/playback/VideoSurfaceTexture.kt b/addons/video/src/main/java/ru/hollowhorizon/hollowengine/addons/video/playback/VideoSurfaceTexture.kt index a60af239d..a0bdbca8f 100644 --- a/addons/video/src/main/java/ru/hollowhorizon/hollowengine/addons/video/playback/VideoSurfaceTexture.kt +++ b/addons/video/src/main/java/ru/hollowhorizon/hollowengine/addons/video/playback/VideoSurfaceTexture.kt @@ -234,7 +234,7 @@ class VideoSurfaceTexture : AutoCloseable { val cullWasEnabled = GL11.glIsEnabled(GL11.GL_CULL_FACE) GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, framebuffer) - GL11.glViewport(0, 0, width, height) + GlStateManager._viewport(0, 0, width, height) if (blendWasEnabled) GlStateManager._disableBlend() if (scissorWasEnabled) GlStateManager._disableScissorTest() if (depthWasEnabled) GlStateManager._disableDepthTest() @@ -263,7 +263,7 @@ class VideoSurfaceTexture : AutoCloseable { GL20.glUseProgram(previousProgram) GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, previousFramebuffer) - GL11.glViewport(previousViewport[0], previousViewport[1], previousViewport[2], previousViewport[3]) + GlStateManager._viewport(previousViewport[0], previousViewport[1], previousViewport[2], previousViewport[3]) if (blendWasEnabled) GlStateManager._enableBlend() if (scissorWasEnabled) GlStateManager._enableScissorTest() if (depthWasEnabled) GlStateManager._enableDepthTest() diff --git a/bootstrap-fabric/build.gradle.kts b/bootstrap-fabric/build.gradle.kts index 2a2704707..9bc5eaee0 100644 --- a/bootstrap-fabric/build.gradle.kts +++ b/bootstrap-fabric/build.gradle.kts @@ -50,16 +50,12 @@ loom { runs { configureEach { - runDir("../run") + runDirectory.set(rootProject.layout.projectDirectory.dir("run")) if (name == "client") { - programArgs("--username", "TheHollowHorizon") + programArguments.addAll("--username", "TheHollowHorizon") } } } - - settingsPostEdit.add { - it.runDirIdeaUrl = "file://\$PROJECT_DIR\$/run" - } } configurations { @@ -117,14 +113,14 @@ dependencies { val fabricApi = "net.fabricmc.fabric-api:fabric-api:$fabricApiVersion" modImplementation(fabricApi) include(fabricApi) - modImplementation("lib:iris-fabric:1.8.8+mc1.21.1") - modImplementation("lib:sodium-fabric:0.6.13+mc1.21.1") + modImplementation("lib:iris-fabric:1.8.14-beta.1+mc1.21.1-devpatch") + modImplementation("lib:sodium-fabric:0.8.13+mc1.21.1") val mixinExtras = "io.github.llamalad7:mixinextras-fabric:0.4.1" modImplementation(mixinExtras) include(mixinExtras) implementation("org.anarres:jcpp:1.4.14") - implementation("io.github.douira:glsl-transformer:2.0.1") + implementation("io.github.douira:glsl-transformer:3.0.0-pre3") add("embeddedRuntime", project(path = ":runtime", configuration = "embeddedRuntimeElements")) add("payloadRemapTable", project(path = ":runtime", configuration = "payloadRemapTableElements")) diff --git a/bootstrap-neoforge/build.gradle.kts b/bootstrap-neoforge/build.gradle.kts index 3a4e29f62..5f6d11397 100644 --- a/bootstrap-neoforge/build.gradle.kts +++ b/bootstrap-neoforge/build.gradle.kts @@ -51,16 +51,12 @@ loom { runs { configureEach { - runDir("../run") + runDirectory.set(rootProject.layout.projectDirectory.dir("run")) if (name == "client") { - programArgs("--username", "TheHollowHorizon") + programArguments.addAll("--username", "TheHollowHorizon") } } } - - settingsPostEdit.add { - it.runDirIdeaUrl = "file://\$PROJECT_DIR\$/run" - } } configurations { @@ -110,14 +106,14 @@ dependencies { neoForge("net.neoforged:neoforge:$neoForgeVersion") - implementation("lib:iris-neoforge:1.8.12+mc1.21.1") - runtimeOnly("lib:iris-neoforge:1.8.12+mc1.21.1") + implementation("lib:iris-neoforge:1.8.14-beta.1+mc1.21.1") + runtimeOnly("lib:iris-neoforge:1.8.14-beta.1+mc1.21.1") - implementation("lib:sodium-neoforge:0.6.13+mc1.21.1") - runtimeOnly("lib:sodium-neoforge:0.6.13+mc1.21.1") + implementation("lib:sodium-neoforge:0.8.13+mc1.21.1") + runtimeOnly("lib:sodium-neoforge:0.8.13+mc1.21.1") implementation("org.anarres:jcpp:1.4.14") - implementation("io.github.douira:glsl-transformer:2.0.1") + implementation("io.github.douira:glsl-transformer:3.0.0-pre3") val mixinExtras = "io.github.llamalad7:mixinextras-neoforge:0.4.1" implementation(mixinExtras) diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/GameRendererMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/GameRendererMixin.java index d5e80494b..96499a02f 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/GameRendererMixin.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/GameRendererMixin.java @@ -1,6 +1,8 @@ package ru.hollowhorizon.hollowengine.bootstrap.mixins.client; import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GameRenderer; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -19,6 +21,11 @@ private void onRenderItemInHand(Camera camera, float partialTick, Matrix4f proje } } + @Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/GameRenderer;tryTakeScreenshotIfNeeded()V")) + private void onLevelFrameRendered(DeltaTracker deltaTracker, boolean renderLevel, CallbackInfo ci) { + BootstrapRuntimeManager.bridge().onLevelFrameRendered(Minecraft.getInstance()); + } + @Inject(method = "getFov", at = @At("RETURN"), cancellable = true) private void onGetFov(Camera camera, float partialTick, boolean changingFov, CallbackInfoReturnable cir) { double fov = BootstrapRuntimeManager.bridge().onCameraFov( diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/MinecraftMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/MinecraftMixin.java index 007b42dcc..af35a3ecf 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/MinecraftMixin.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/MinecraftMixin.java @@ -56,11 +56,6 @@ private void onStopHead(CallbackInfo ci) { BootstrapRuntimeManager.bridge().onClientStopping((Minecraft) (Object) this); } - @Inject(method = "resizeDisplay", at = @At("RETURN")) - private void onResizeDisplay(CallbackInfo ci) { - BootstrapRuntimeManager.bridge().onClientResized((Minecraft) (Object) this); - } - @Inject(method = "setLevel", at = @At("HEAD")) private void onSetClientLevel(ClientLevel newLevel, ReceivingLevelScreen.Reason reason, CallbackInfo ci) { if (level != newLevel) hollowengine$releaseLevel(); diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/WindowMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/WindowMixin.java index 3e1db0667..1599f52cc 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/WindowMixin.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/WindowMixin.java @@ -16,7 +16,7 @@ public class WindowMixin { @Inject(method = "getWidth", at = @At("HEAD"), cancellable = true) private void hollowengine$getWidth(CallbackInfoReturnable cir) { RuntimeBridge.GameViewportMetrics metrics = BootstrapRuntimeManager.bridge().getGameViewportMetrics(); - if (metrics != null && BootstrapRuntimeManager.bridge().isGameViewportRendering()) { + if (metrics != null && !BootstrapRuntimeManager.bridge().isGameViewportWindowPass()) { cir.setReturnValue(metrics.framebufferWidth()); } } @@ -24,7 +24,7 @@ public class WindowMixin { @Inject(method = "getHeight", at = @At("HEAD"), cancellable = true) private void hollowengine$getHeight(CallbackInfoReturnable cir) { RuntimeBridge.GameViewportMetrics metrics = BootstrapRuntimeManager.bridge().getGameViewportMetrics(); - if (metrics != null && BootstrapRuntimeManager.bridge().isGameViewportRendering()) { + if (metrics != null && !BootstrapRuntimeManager.bridge().isGameViewportWindowPass()) { cir.setReturnValue(metrics.framebufferHeight()); } } diff --git a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/iris/IrisRenderingPipelineMixin.java b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/iris/IrisRenderingPipelineMixin.java index 9cfd77764..590b069dc 100644 --- a/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/iris/IrisRenderingPipelineMixin.java +++ b/bootstrap/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/mixins/client/iris/IrisRenderingPipelineMixin.java @@ -14,6 +14,7 @@ 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; import ru.hollowhorizon.hollowengine.fabric.internal.accessors.IrisRenderingPipelineAccessor; import java.io.IOException; @@ -28,6 +29,11 @@ public abstract class IrisRenderingPipelineMixin implements IrisRenderingPipelin hollowengine$programSet = set; } + @Inject(method = "finalizeLevelRendering", at = @At("RETURN")) + private void hollowengine$afterFinalPass(CallbackInfo ci) { + BootstrapRuntimeManager.bridge().onIrisLevelFinished(); + } + @Override public ProgramSet getProgramSet() { return hollowengine$programSet; diff --git a/bridge/build.gradle.kts b/bridge/build.gradle.kts index 13c8f7893..2912250bd 100644 --- a/bridge/build.gradle.kts +++ b/bridge/build.gradle.kts @@ -47,7 +47,7 @@ dependencies { parchment("org.parchmentmc.data:parchment-$minecraftVersion:$parchmentVersion") }) - modImplementation("lib:iris-fabric:1.8.8+mc1.21.1") + modImplementation("lib:iris-fabric:1.8.14-beta.1+mc1.21.1-devpatch") modImplementation("net.fabricmc:fabric-loader:$fabricLoaderVersion") compileOnly("org.spongepowered:mixin:0.8.7") 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 f4e0a6ec3..16b100963 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 @@ -180,8 +180,6 @@ public interface RuntimeBridge extends AutoCloseable { void onClientRenderTickPost(Minecraft client); - void onClientResized(Minecraft client); - void onClientStopping(Minecraft client); void onLevelCreated(Level level); @@ -247,6 +245,12 @@ public interface RuntimeBridge extends AutoCloseable { void onIrisShadowRenderEnd(); + /** + * The shader pack has written its final image into the main target. What should look the same + * with and without a pack is drawn from here, over the finished frame and its depth. + */ + void onIrisLevelFinished(); + /** * Fired around a HUD layer. The layer is identified by its resource-location string * (e.g. {@code "minecraft:crosshair"}) rather than a fixed enum, so NeoForge can pass every @@ -277,13 +281,13 @@ public interface RuntimeBridge extends AutoCloseable { /** * Virtual framebuffer and GUI dimensions used while the editor embeds the game in a dock. - * GUI dimensions remain active for input and screen layout; framebuffer dimensions are only - * exposed while {@link #isGameViewportRendering()} is true, so the IDE itself still renders - * against the real window. + * GUI dimensions are always answered; framebuffer dimensions are answered everywhere except + * the window pass ({@link #isGameViewportWindowPass()}), where the finished frame and the + * editor are drawn onto the real window. */ @Nullable GameViewportMetrics getGameViewportMetrics(); - boolean isGameViewportRendering(); + boolean isGameViewportWindowPass(); void onRenderLevelStage(LevelRenderer renderer, PoseStack poseStack, Matrix4f projectionMatrix, int ticks, float partialTick, Camera camera, @Nullable Frustum frustum, RenderLevelStage stage); @@ -308,6 +312,8 @@ default void close() { void initRegistryProvider(RegistryProvider provider); + void onLevelFrameRendered(Minecraft minecraft); + void onBeforeBlitScreen(Minecraft minecraft); void onBlitScreen(Minecraft minecraft); diff --git a/build.gradle.kts b/build.gradle.kts index a5a35ee85..a64f6a356 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -255,9 +255,13 @@ publishMods { } tasks.named("buildAndCollect") { + dependsOn(buildAddons) + val compilerJar = project(":addons:compiler").tasks.named("addonJar") - dependsOn(compilerJar) + val mcpJar = project(":addons:mcp").tasks.named("addonJar") + from(compilerJar.flatMap { it.archiveFile }) + from(mcpJar.flatMap { it.archiveFile }) } val buildAddons = tasks.register("buildAddons") { @@ -282,8 +286,4 @@ gradle.projectsEvaluated { dependsOn(compilerJar) from(compilerJar.flatMap { it.archiveFile }) } -} - -tasks.named("buildAndCollect") { - dependsOn(buildAddons) -} +} \ No newline at end of file diff --git a/docs/assets/pictures/ide/client-reload-script.webp b/docs/assets/pictures/ide/client-reload-script.webp index 463204ca2..4183a406a 100644 Binary files a/docs/assets/pictures/ide/client-reload-script.webp and b/docs/assets/pictures/ide/client-reload-script.webp differ diff --git a/docs/assets/pictures/ide/code-editor.webp b/docs/assets/pictures/ide/code-editor.webp index e453a313b..c58f85ccc 100644 Binary files a/docs/assets/pictures/ide/code-editor.webp and b/docs/assets/pictures/ide/code-editor.webp differ diff --git a/docs/assets/pictures/ide/code-navigation.webp b/docs/assets/pictures/ide/code-navigation.webp index 727212067..39b4b71be 100644 Binary files a/docs/assets/pictures/ide/code-navigation.webp and b/docs/assets/pictures/ide/code-navigation.webp differ diff --git a/docs/assets/pictures/ide/kotlin-console.webp b/docs/assets/pictures/ide/kotlin-console.webp new file mode 100644 index 000000000..def931366 Binary files /dev/null and b/docs/assets/pictures/ide/kotlin-console.webp differ diff --git a/docs/assets/pictures/ide/new-script-menu.webp b/docs/assets/pictures/ide/new-script-menu.webp index 524c1f55c..1e2211b4d 100644 Binary files a/docs/assets/pictures/ide/new-script-menu.webp and b/docs/assets/pictures/ide/new-script-menu.webp differ diff --git a/docs/assets/pictures/ide/reload-script.webp b/docs/assets/pictures/ide/reload-script.webp index 3db27a3bc..0f4408709 100644 Binary files a/docs/assets/pictures/ide/reload-script.webp and b/docs/assets/pictures/ide/reload-script.webp differ diff --git a/docs/assets/pictures/ide/toolbar-visibility.webp b/docs/assets/pictures/ide/toolbar-visibility.webp index e06cad22e..f39b514f3 100644 Binary files a/docs/assets/pictures/ide/toolbar-visibility.webp and b/docs/assets/pictures/ide/toolbar-visibility.webp differ diff --git a/docs/assets/pictures/ide/workspace.webp b/docs/assets/pictures/ide/workspace.webp index 04e829e07..118bba575 100644 Binary files a/docs/assets/pictures/ide/workspace.webp and b/docs/assets/pictures/ide/workspace.webp differ diff --git a/docs/assets/pictures/project-export/actions.webp b/docs/assets/pictures/project-export/actions.webp index 130da0218..d04eb6046 100644 Binary files a/docs/assets/pictures/project-export/actions.webp and b/docs/assets/pictures/project-export/actions.webp differ diff --git a/docs/assets/pictures/project-export/export-dialog.webp b/docs/assets/pictures/project-export/export-dialog.webp new file mode 100644 index 000000000..1b44d5352 Binary files /dev/null and b/docs/assets/pictures/project-export/export-dialog.webp differ diff --git a/docs/assets/pictures/project-export/export-success.webp b/docs/assets/pictures/project-export/export-success.webp index 08f5d273a..ee2c9e635 100644 Binary files a/docs/assets/pictures/project-export/export-success.webp and b/docs/assets/pictures/project-export/export-success.webp differ diff --git a/docs/assets/pictures/project-export/import-dialog.webp b/docs/assets/pictures/project-export/import-dialog.webp index 2e2e5472f..2083b6c18 100644 Binary files a/docs/assets/pictures/project-export/import-dialog.webp and b/docs/assets/pictures/project-export/import-dialog.webp differ diff --git a/docs/assets/pictures/project-export/settings.webp b/docs/assets/pictures/project-export/settings.webp index 784f7f0c5..b82278740 100644 Binary files a/docs/assets/pictures/project-export/settings.webp and b/docs/assets/pictures/project-export/settings.webp differ diff --git a/docs/assets/pictures/vfx/editor-overview.webp b/docs/assets/pictures/vfx/editor-overview.webp new file mode 100644 index 000000000..daa69d1bd Binary files /dev/null and b/docs/assets/pictures/vfx/editor-overview.webp differ diff --git a/docs/en/commands.mdx b/docs/en/commands.mdx index dbfb6bf46..61c6049c6 100644 --- a/docs/en/commands.mdx +++ b/docs/en/commands.mdx @@ -17,6 +17,19 @@ The mod adds quite a few useful commands, which can be accessed via two main com --- +## VFX + +Play a `.vfx` effect at a position or on an entity: + +```mcfunction +/he vfx ~ ~1 ~ project:sparks +/he vfx @s project:sparks +``` + +`project:sparks` refers to `hollowengine/assets/project/vfx/sparks.vfx`. See [Visual effects (VFX)](/docs/hollowengine/vfx) for creating the file and using the editor. + +--- + # Particles | Command | Description | diff --git a/docs/en/guide/index.mdx b/docs/en/guide/index.mdx index fe35e9532..b5d8aa7b8 100644 --- a/docs/en/guide/index.mdx +++ b/docs/en/guide/index.mdx @@ -12,11 +12,11 @@ order: 1 First, install the mod and its compiler. The latest version is always available on [GitHub](https://github.com/BMProjects-Development/HollowEngine/releases). Download both `.jar` files and place them in your `mods` folder. The compiler addon is only required during development. Once you export your project as a standalone mod, it will run without the compiler as long as HollowEngine itself is installed. -After installing the mod and launching the game, look for the gear icon in the upper-left corner of the screen. Click it to open the mod's main panel, which contains several tabs: +After installing the mod and launching the game, click the HollowEngine logo in the upper-left corner. It opens the IDE workspace with four menus: - File: Useful actions such as opening the mod folder and reloading client and server resources. - Windows: The engine's main tools, including project files, cutscenes, editors, and more. -- Tools: Additional utilities; currently, this section contains model transformation controls. +- Tools: Editor mode, transform controls, the UI profiler, and interface scale. - Help: Useful information such as links to the project community and documentation. ![HollowEngine IDE workspace](/hollowengine/pictures/ide/workspace.webp) @@ -25,7 +25,7 @@ For more information about the editor, see [Working with the IDE](/docs/hollowen ## Checking whether the compiler works -Once you enter a world, you can check whether the compiler is working. Open the Terminal panel and enable Kotlin script mode. +Once you enter a world, you can check whether the compiler is working. Open **Windows > Console**, switch to the **Logs** tab, and select Kotlin script mode in the input line. You can now use code to send a message to yourself in chat: ![First script](/hollowengine/pictures/guide/first-script.webp) diff --git a/docs/en/ide.mdx b/docs/en/ide.mdx index d378076e7..1c97d3148 100644 --- a/docs/en/ide.mdx +++ b/docs/en/ide.mdx @@ -11,14 +11,16 @@ order: 4 The HollowEngine logo in the upper-left corner controls the IDE overlay. Left-click it to expand or collapse the workspace. The expanded toolbar contains four menus: -- **File** - project folder and resource reload actions; +- **File** - saving, project settings, import and export, project folder, and resource reload actions; - **Windows** - Project, Asset Manager, Console, Cutscenes, editors, and other panels; -- **Tools** - model transformation and other context tools; +- **Tools** - editor mode, transform controls, UI profiler, and interface scale; - **Help** - documentation and community links. -Panels open as docked tabs. Drag a tab to rearrange the workspace, drop it near an edge to split the area, or drag separators to resize neighboring panels. `Ctrl + W` closes the focused tab. +Panels open as docked tabs. Drag a tab into a highlighted target to dock, split, or pin the panel to a side stripe; drag separators to resize panels. A pinned panel can collapse to a stripe button and expand again. Use **Windows > Side Bars** to toggle the stripes, or **Windows > Reset Window Layout** to restore the default arrangement. `Ctrl + W` closes the focused tab. -![IDE workspace and docking](/hollowengine/pictures/ide/workspace.webp) +The status bar at the bottom shows the open file or current object path and editor details. Click a path segment to reveal it in the project or open its tool window. + +![IDE workspace with Project, editor, and Console](/hollowengine/pictures/ide/workspace.webp) ## Project tree @@ -35,8 +37,6 @@ When the Project panel is focused, these shortcuts are available: | `Alt + Insert` | Create a file | | `Alt + Shift + Insert` | Create a folder | -![Project tree and context menu](/hollowengine/pictures/ide/project-tree.webp) - ## Creating scripts Right-clicking the `scripts` folder or any folder inside it shows the **New Script** item. Hover over it and pick a type: @@ -49,17 +49,26 @@ Right-clicking the `scripts` folder or any folder inside it shows the **New Scri | **Startup Script** | `.startup.kts` | an item registered under the file's name | | **Node Script** | `.node.kts` | start, stop, and once-a-second handlers | | **UI Script** | `.ui.kts` | a simple screen | +| **Mixin Script** | `.mixin.kts` | a script mixin starter | | **Dialogue** | `.story` | a dialogue with two answers | Then enter the file name. The extension can be omitted, the IDE adds it. The new file opens in the editor right away with a working example to start from. -![The new script menu](/hollowengine/pictures/ide/new-script-menu.webp) - The item is only offered inside `scripts`, because the engine loads scripts and dialogues from there only. +![Context menu for creating scripts](/hollowengine/pictures/ide/new-script-menu.webp) + ## Code editor -Opening `.kts`, `.story`, `.hss`, and other supported text files starts the built-in editor. Depending on the file type, it provides syntax highlighting, diagnostics, completion, signature help, inlay hints, and navigation. Diagnostics are shown in the editor and in a resizable problems panel. +Opening `.kts`, `.story`, `.hss`, and other supported text files starts the built-in editor. Depending on the file type, it provides syntax highlighting, diagnostics, completion, signature help, inlay hints, and navigation. Diagnostics appear in the editor and in the **Problems** tab of the **Console** window. Its **Logs** tab shows log output, filters by severity and text, and accepts commands. + +Opening a `.vfx` file starts a visual editor with a preview. The node tree appears in **Scene**, the selected node's settings in **Inspector**, and animation in **Timeline**. See [Visual effects (VFX)](/docs/hollowengine/vfx). + +![Kotlin script completion in the code editor](/hollowengine/pictures/ide/code-editor.webp) + +In Kotlin mode, the Console's **Logs** tab can run short code snippets and show their output in the log. + +![Running Kotlin code in the IDE Console](/hollowengine/pictures/ide/kotlin-console.webp) Useful editor shortcuts: @@ -73,8 +82,6 @@ Useful editor shortcuts: | `Ctrl + Alt + L` | Format the focused file | | `Ctrl + N` | Search across the project; selected editor text becomes the initial query | -![Completion and diagnostics in the code editor](/hollowengine/pictures/ide/code-editor.webp) - ## Reloading changes Saving a file writes it to the `hollowengine` directory. What must be reloaded depends on the file: @@ -99,7 +106,7 @@ Right-clicking the logo opens visibility options: The **Hide** action asks for confirmation and explains how to recover the toolbar. Select **Don't show again** only if you do not want this warning on later hides. -![Toolbar visibility menu and hide confirmation](/hollowengine/pictures/ide/toolbar-visibility.webp) +![Confirmation before hiding the HollowEngine toolbar](/hollowengine/pictures/ide/toolbar-visibility.webp) If the toolbar is already hidden, open `config/hollowengine.toml` and set: diff --git a/docs/en/index.mdx b/docs/en/index.mdx index 095bae229..74b7cf998 100644 --- a/docs/en/index.mdx +++ b/docs/en/index.mdx @@ -16,7 +16,7 @@ HollowEngine is a full-featured platform for creating content within Minecraft: - Internal IDE on top of the game client; - Kotlin scripts (`.kts`) with coroutines, access to the game world, and persistent script state; -- NPCs, 3D models, animation controllers, and highly customizable light sources; +- NPCs, 3D models, animation controllers, visual effects, and highly customizable light sources; ## Installation @@ -31,6 +31,8 @@ Addons can be placed either in `mods` beside HollowEngine or in `hollowengine/ad The documentation contains many different sections, and properly understanding how to create simple projects would otherwise require reading roughly **70% of the entire documentation**. To make getting started easier, see [Getting Started with HollowEngine](/docs/hollowengine/guide), which explains where to begin learning the engine. +For effects, see the [VFX editor](/docs/hollowengine/vfx); for project files and scripts, see [Working with the IDE](/docs/hollowengine/ide). + ## Work directory Virtually all user-generated content is stored in the `hollowengine` directory, which the mod creates in the game folder. Inside it, the most important files are: diff --git a/docs/en/project-export.mdx b/docs/en/project-export.mdx index 14480bcca..2d12af454 100644 --- a/docs/en/project-export.mdx +++ b/docs/en/project-export.mdx @@ -13,13 +13,13 @@ Starting with HollowEngine 2.3.2, a project can be built into a `.jar` archive t An exported project is still a HollowEngine addon. It will not start unless HollowEngine 2.3.2 or newer is installed. ::: -## Project toolbar +## Project actions -The **Project** panel provides three actions: +The three actions are available both as buttons in the **Project** panel and in the **File** menu: 1. **Project Settings** - metadata and dependencies for the future mod. This is also where you configure the dependent mods and add-ons that you can use in your scripts during development. -2. **Import Project** - restores a previously exported project, provided the source code was included in the archive. -3. **Export Project** - compiles scripts and builds the finished `.jar` archive. If necessary, you can disable the export of the project’s source code here; however, this will make it impossible to import it back later. +2. **Import a project** - restores a previously exported project, provided the source code was included in the archive. +3. **Export as an addon** - compiles scripts and builds the finished `.jar` archive. You can disable export of the project's source code here, but this prevents importing it back later. ![Settings, import, and export buttons in the Project panel](/hollowengine/pictures/project-export/actions.webp) @@ -45,7 +45,9 @@ Select **Save** after changing the settings. ## Exporting the project -Select **Export project**, then: +Click **Export as an addon** in the **Project** panel or choose **File > Export as an addon**, then: + +![Project export dialog](/hollowengine/pictures/project-export/export-dialog.webp) 1. Check the version of the resulting mod. 2. Choose whether to include script sources. @@ -80,7 +82,7 @@ Once HollowEngine 2.3.2 is released, the project can be published on CurseForge ## Importing a project -To continue working on a previously exported project, select **Import project** and choose its JAR file. +To continue working on a previously exported project, click **Import a project** in the **Project** panel or choose **File > Import a project**, then select its JAR file. ![Selecting a JAR to import](/hollowengine/pictures/project-export/import-dialog.webp) diff --git a/docs/en/scripting/ui/particles.mdx b/docs/en/scripting/ui/particles.mdx index a54829f87..fba980e43 100644 --- a/docs/en/scripting/ui/particles.mdx +++ b/docs/en/scripting/ui/particles.mdx @@ -70,9 +70,9 @@ thread. Do not retain a `UiParticle`: dead particles are pooled and reused. Vani tints are not inferred; set `color` in `spawn` for effects such as colored dust. Multiple emitters can share one system and its `maxParticles` budget (512 by default). -When full, births are dropped without a backlog; emitters claim capacity in declaration order. +When full, spawns are dropped without a backlog; emitters claim capacity in declaration order. `seed` makes randomness reproducible. Set `paused` to freeze the simulation or `emitting` -to false to stop new births while existing particles expire. Call `reset()` on the render +to false to stop new spawns while existing particles expire. Call `reset()` on the render thread to clear particles and timing and re-enable the configured initial burst. ## Lifecycle and rendering diff --git a/docs/en/scripting/ui/styles/hss-files.mdx b/docs/en/scripting/ui/styles/hss-files.mdx index bc5fbf4de..718071209 100644 --- a/docs/en/scripting/ui/styles/hss-files.mdx +++ b/docs/en/scripting/ui/styles/hss-files.mdx @@ -25,6 +25,27 @@ HSS is ideal for palettes, repeated geometry, transitions, and pseudo-states. Us Stylesheets live in files. Scripts import them by resource location, so the styling of a screen stays readable and reloadable on its own; there is no API for compiling a stylesheet out of a string inside a script. +## Imports and variables + +A stylesheet can import other HSS files, store values in `$name` variables, and reuse declaration sets with `@apply`: + +```css +@import "project:ui/styles/palette.hss"; + +$accent: #6AA9FF; +$card { + padding: 12px; + border-radius: 6px; +} + +.card { + @apply $card; + background: $accent; +} +``` + +Imported rules come before the current file's rules. Local variables and sets override imported names; each import is read once, including imports in a cycle. Variables can refer to other variables and be used inside sets. Unknown names and recursive substitutions produce an error where the value is used. The built-in HSS editor reports diagnostics for imports, variables, and `@apply`. + ## Selectors | Selector | Matches | diff --git a/docs/en/vfx.mdx b/docs/en/vfx.mdx new file mode 100644 index 000000000..f8ad3139b --- /dev/null +++ b/docs/en/vfx.mdx @@ -0,0 +1,65 @@ +--- +title: Visual effects (VFX) +order: 6 +--- + +

Visual effects (VFX)

+ +A VFX effect is stored in a `.vfx` file and built from a tree of nodes. One effect can combine particles, geometric surfaces, trails, beams, screen effects, sky effects, and camera shake. The built-in editor opens `.vfx` files as IDE documents. + +![VFX editor with node tree, preview, Inspector, and Timeline](/hollowengine/pictures/vfx/editor-overview.webp) + +## Creating and placing a file + +Create an empty `hollowengine/assets/project/vfx/sparks.vfx` file through **New File** in the **Project** panel's context menu or your file manager. An empty `.vfx` opens as a new effect; the editor writes its structure when you save it. + +Double-click the file in the project tree. In the **Scene** window, right-click the effect root and choose **Add Node**. Start with an **Emitter**: it is created with a child plane that draws its particles. Edit the selected node in the **Inspector**, and property animation keys in the **Timeline**. Open **Scene**, **Inspector**, and **Timeline** from the **Windows** menu if needed. + +The effect is available as `project:vfx/sparks.vfx`. Playback also accepts the short name `project:sparks`. After changing a resource, use **File > Reload Client Resources** or `F3 + T` to update it in-game. The editor automatically saves a changed `.vfx` after a short pause; `Ctrl + S` saves it immediately. + +## Effect structure + +| Node | Purpose | +| --- | --- | +| Group | Groups children under one transform. | +| Emitter | Spawns particles; child nodes draw each particle. | +| Plane, Cube, Sphere, Cylinder, Model | Draw surfaces or model geometry. | +| Trail, Beam | Draw a trail or beam. | +| Post Effect, Sky, Camera Shake | Control a screen effect, sky effect, or camera shake. | + +An emitter controls rate and burst, duration, spawn shape, speed, lifetime, and local or world simulation space. Optional modules add forces, noise, collisions, velocity changes, and UV animation. Child renderers can each have their own size, rotation, color, and material. + +The **Scene** window lets you add child nodes, rename, duplicate, disable, remove, and move them. Edit the selected node in the **Inspector**. Orbit the preview camera, zoom with the wheel, and use transform tools to position nodes. Preview controls play or restart the effect and toggle the floor and emitter shape. The Timeline stores property tracks and supports looping playback. + +## Values, curves, and materials + +A numeric field can hold a constant, a random value from a range, a curve, or an expression. A curve can sample particle lifetime progress, particle age, emitter age, effect time, or speed. For example, this can shrink a particle near the end of its life without keys on the overall Timeline. Use Timeline tracks to animate node properties over effect time. + +Expressions can read particle data (`p.age`, `p.speed`, `p.progress`), emitter data (`e.age`, `e.count`), camera distance (`n.camera_distance`), and values supplied to the running effect (`d.name`). Scripts send these values through the `VfxHandle`; a missing numeric value reads as `0`. A material controls its texture, lighting, and blend mode: `OPAQUE`, `BLEND`, `ADDITIVE`, or `MULTIPLY`. You can select a UV region within an atlas and use the UV animation module to advance its frames. + +## Sub-emitters + +Place an **Emitter** directly inside another emitter and enable **Spawn from parent particles** under **Sub-emitter**. The child emitter spawns particles when a parent particle triggers the selected event instead of using its own rate and burst. Its spawn shape is centered on the event. **Limit** remains available under **Emission** to cap the number of living particles. + +The events are **Spawn**, **Death**, **Collision**, and **While alive**. For the first three, **Count** is the number of particles per event; for **While alive**, it is the number per second for each parent particle. **Collision** requires collision handling on the parent emitter. **Inertia** adds the selected fraction of the parent's velocity to new particles. + +For a firework, create a rocket emitter, nest a burst emitter inside it, and select **Death** for the burst. In **Count** and **Inertia** expressions, `p.` refers to the parent particle that triggered the event. Its random number is available to spawned particles as `p.parent_random` or the **Parent random** curve source, allowing the rocket and burst to share a color. + +## Soft particles and glow + +On a **Plane**, **Cube**, **Sphere**, **Cylinder**, **Model**, **Trail**, or **Beam** material, **Soft intersection** sets the distance in blocks over which the surface fades near scene geometry. This avoids a hard edge where a particle intersects the ground or a block. `0` disables the fade. + +**Glow** adds a blurred halo in the surface's color, respecting scene depth. `0` disables it; higher values strengthen the halo. **Additive** blending changes the surface itself and does not enable the glow pass on its own. You can use both settings together. The glow contribution is not clamped to 1 in its `RGBA16F` buffer. A custom shader participates in the glow pass only if it declares `GlowPass` and uses the `Glow` value; it must also implement soft intersections itself. + +## Playing an effect in-game + +The `/he vfx` command accepts a position or an entity and an effect name: + +```mcfunction +/he vfx ~ ~1 ~ project:sparks +/he vfx @s project:sparks +``` + +From Kotlin, use `Vfx.play(level, position, "project:sparks")` or `Vfx.play(entity, "project:sparks")`. Both return a `VfxHandle`: `stop()` stops emission, `stop(immediate = true)` clears the effect at once, and `moveTo(position)` moves a point-anchored effect. Entity-anchored effects follow their entity automatically, so `moveTo` has no effect on them. + +The effect name maps to a client resource path. If it does not play, check the path and IDE console messages after reloading resources. diff --git a/docs/ru/commands.mdx b/docs/ru/commands.mdx index 8095cab95..b695ff3d3 100644 --- a/docs/ru/commands.mdx +++ b/docs/ru/commands.mdx @@ -17,6 +17,19 @@ order: 3 --- +## VFX + +Эффекты из `.vfx` запускаются по позиции или на сущности: + +```mcfunction +/he vfx ~ ~1 ~ project:sparks +/he vfx @s project:sparks +``` + +`project:sparks` соответствует ресурсу `hollowengine/assets/project/vfx/sparks.vfx`. Подробнее о создании файла и редакторе написано в разделе [Визуальные эффекты (VFX)](/docs/hollowengine/vfx). + +--- + # Партиклы | Команда | Описание | diff --git a/docs/ru/guide/index.mdx b/docs/ru/guide/index.mdx index 37ba7c4d4..0c52af119 100644 --- a/docs/ru/guide/index.mdx +++ b/docs/ru/guide/index.mdx @@ -12,10 +12,10 @@ order: 1 Для начала установите сам мод и компилятор для него. Актуальная версия всегда есть на [GitHub](https://github.com/BMProjects-Development/HollowEngine/releases), вам необходимо скачать оба `.jar` файла и положить их в папку `mods`. Аддон на компилятор нужен только во время разработки, после экспорта вашего проекта в полноценный мод, он запустится и без компилятора, главное чтобы сам HollowEngine был в списке модов. -Скачав мод и запустив игру, обратите внимание на шестерёнку в левом верхнем углу экрана. Нажав на неё, вы откроете главную панель мода с несколькими вкладками: +Скачав мод и запустив игру, нажмите логотип HollowEngine в левом верхнем углу экрана. Он откроет рабочее пространство IDE с четырьмя меню: - Файл: Раздел с полезными функциями - открытие папки мода, перезагрузки клиентских и серверных ресурсов. - Окна: Раздел с разными окнами движка, здесь уже лежит основной функционал движка - файлы проекта, катсцены, редакторы и т.п. -- Инструменты: Раздел с вспомогательными функциями, сейчас там только управление трансформациями моделей. +- Инструменты: Режим редактора, трансформации, UI-профилировщик и масштаб интерфейса. - Помощь: Раздел с полезной информацией вроде ссылок на сообщество проекта или документацией. ![Рабочее пространство IDE HollowEngine](/hollowengine/pictures/ide/workspace.webp) @@ -24,7 +24,7 @@ order: 1 ## Проверяем, работает ли компилятор? -Зайдя в мир, можем проверить, работает ли компилятор. Для этого откройте панель Терминала и включите режим Kotlin-скриптов. +Зайдя в мир, можем проверить, работает ли компилятор. Для этого откройте **Окна > Консоль**, перейдите на вкладку **Логи** и включите режим Kotlin-скриптов в строке ввода. Теперь, можно отправить сообщение самому себе в чат через код: ![Первый скрипт](/hollowengine/pictures/guide/first-script.webp) diff --git a/docs/ru/ide.mdx b/docs/ru/ide.mdx index 01824326d..c859c620f 100644 --- a/docs/ru/ide.mdx +++ b/docs/ru/ide.mdx @@ -11,14 +11,16 @@ order: 4 Логотип HollowEngine в левом верхнем углу управляет оверлеем IDE. Левый клик разворачивает или сворачивает рабочее пространство. На раскрытой панели есть четыре меню: -- **Файл** - папка проекта и перезагрузка ресурсов; +- **Файл** - сохранение, настройки, импорт и экспорт проекта, папка проекта и перезагрузка ресурсов; - **Окна** - проект, менеджер ассетов, консоль, катсцены, редакторы и другие панели; -- **Инструменты** - трансформации моделей и другие контекстные инструменты; +- **Инструменты** - режим редактора, трансформации, UI-профилировщик и масштаб интерфейса; - **Помощь** - ссылки на документацию и сообщество. -Панели открываются как окна или вкладки в браузере. Перетащите вкладку, чтобы изменить её положение, либо отпустите её в нужной зоне для разделения области или тяните разделитель, чтобы изменить размер соседних панелей. `Ctrl + W` закрывает активную вкладку. +Панели открываются как вкладки в рабочем пространстве. Перетащите вкладку в подсвеченную область для докинга, разделения или закрепления панели на боковой полосе; тяните разделитель, чтобы изменить размер. Закреплённую панель можно свернуть до кнопки и снова развернуть. Полосы включаются через **Окна > Боковые полоски**, а исходную раскладку можно вернуть через **Окна > Сбросить расположение окон**. `Ctrl + W` закрывает активную вкладку. -![Рабочее пространство и докинг IDE](/hollowengine/pictures/ide/workspace.webp) +В нижней строке состояния отображаются путь к открытому файлу или текущему объекту и сведения о редакторе. Нажатие на сегмент пути открывает соответствующее место в проекте или окно инструмента. + +![Рабочее пространство IDE с проектом, редактором и консолью](/hollowengine/pictures/ide/workspace.webp) ## Дерево проекта @@ -35,31 +37,38 @@ order: 4 | `Alt + Insert` | Создать файл | | `Alt + Shift + Insert` | Создать папку | -![Дерево проекта и контекстное меню](/hollowengine/pictures/ide/project-tree.webp) - ## Создание скриптов Правый клик по папке `scripts` или любой папке внутри неё открывает пункт **New Script**. Наведите на него курсор и выберите тип: -| Пункт | Файл | Что внутри | -|--------------------------|---------------------------------------|---------------------------------------------------| -| **Script** | `.kts` | функция и её вызов | -| **Server Reload Script** | `.reload.kts` | приветствие игроку в чате при входе | -| **Client Reload Script** | `.reload.kts` с `@file:ClientSide` | надпись на HUD | -| **Startup Script** | `.startup.kts` | регистрация предмета с именем файла | -| **Node Script** | `.node.kts` | обработчики запуска, остановки и ежесекундного тика | -| **UI Script** | `.ui.kts` | простой экран | -| **Dialogue** | `.story` | диалог с двумя вариантами ответа | +| Пункт | Файл | Что внутри | +|--------------------------|------------------------------------|-----------------------------------------------------| +| **Script** | `.kts` | функция и её вызов | +| **Server Reload Script** | `.reload.kts` | приветствие игроку в чате при входе | +| **Client Reload Script** | `.reload.kts` с `@file:ClientSide` | надпись на HUD | +| **Startup Script** | `.startup.kts` | регистрация предмета с именем файла | +| **Node Script** | `.node.kts` | обработчики запуска, остановки и ежесекундного тика | +| **UI Script** | `.ui.kts` | простой экран | +| **Mixin Script** | `.mixin.kts` | шаблон скриптового миксина | +| **Dialogue** | `.story` | диалог с двумя вариантами ответа | Затем введите имя файла. Расширение можно не писать, IDE добавит его сама. Созданный файл сразу открывается в редакторе с рабочим примером, от которого удобно отталкиваться. -![Меню создания скрипта](/hollowengine/pictures/ide/new-script-menu.webp) - Пункт есть только внутри `scripts`: движок загружает скрипты и диалоги только оттуда. +![Контекстное меню создания скриптов](/hollowengine/pictures/ide/new-script-menu.webp) + ## Редактор кода -При открытии `.kts`, `.story`, `.hss` и других поддерживаемых текстовых файлов запускается встроенный редактор. В зависимости от типа файла он предоставляет подсветку синтаксиса, диагностику, автодополнение, справку по сигнатурам, inlay-подсказки и навигацию. Диагностика показывается в тексте и в отдельной панели проблем с изменяемым размером. +При открытии `.kts`, `.story`, `.hss` и других поддерживаемых текстовых файлов запускается встроенный редактор. В зависимости от типа файла он предоставляет подсветку синтаксиса, диагностику, автодополнение, справку по сигнатурам, inlay-подсказки и навигацию. Диагностика показывается в тексте и на вкладке **Проблемы** окна **Консоль**. Вкладка **Логи** этого же окна показывает журнал, позволяет фильтровать записи по уровню и тексту и выполнять команды. + +Для `.vfx` открывается визуальный редактор с предпросмотром. Дерево узлов находится в окне **Сцена**, параметры выбранного узла в **Инспекторе**, а анимация в **Таймлайне**. Подробности в статье [Визуальные эффекты (VFX)](/docs/hollowengine/vfx). + +![Автодополнение в редакторе Kotlin-скрипта](/hollowengine/pictures/ide/code-editor.webp) + +В режиме Kotlin на вкладке **Логи** консоль позволяет запускать короткие фрагменты кода и показывает их результат в журнале. + +![Запуск Kotlin-кода в консоли IDE](/hollowengine/pictures/ide/kotlin-console.webp) Полезные сочетания редактора: @@ -73,8 +82,6 @@ order: 4 | `Ctrl + Alt + L` | Форматировать активный файл | | `Ctrl + N` | Искать по всему проекту; выделенный текст станет начальным запросом | -![Автодополнение и диагностика в редакторе](/hollowengine/pictures/ide/code-editor.webp) - ## Применение изменений Сохранение записывает файл в папку `hollowengine`. Способ применения зависит от типа файла: @@ -97,9 +104,9 @@ order: 4 - **Скрыть** - отключить логотип и панель; - **Показывать только в чате** - отображать логотип, пока открыт чат. -При выборе **Скрыть** появляется подтверждение с инструкцией по восстановлению. Отмечайте **Больше не показывать**, только если не будете потом спрашивать в чате, как вернуть шестерёнку)) +При выборе **Скрыть** появляется подтверждение с инструкцией по восстановлению. Флажок **Больше не показывать** отключает это предупреждение при последующих скрытиях. -![Меню видимости и подтверждение скрытия](/hollowengine/pictures/ide/toolbar-visibility.webp) +![Подтверждение скрытия панели HollowEngine](/hollowengine/pictures/ide/toolbar-visibility.webp) Если панель уже скрыта, откройте `config/hollowengine.toml` и установите: diff --git a/docs/ru/index.mdx b/docs/ru/index.mdx index 1f0e515f3..1953e9f5a 100644 --- a/docs/ru/index.mdx +++ b/docs/ru/index.mdx @@ -15,7 +15,7 @@ HollowEngine это полноценная платформа для созда - Встроенная IDE поверх игрового клиента; - Полноценный Kotlin скриптинг (`.kts`) с поддержкой корутин, взаимодействием с игровым миром и сохранением состояния скриптов; -- NPC, 3D-модели, анимационные контроллеры, источники света с гибкой настройкой; +- NPC, 3D-модели, анимационные контроллеры, визуальные эффекты и источники света с гибкой настройкой; ## Установка @@ -30,6 +30,8 @@ HollowEngine поддерживает NeoForge и Fabric для Minecraft 1.21.1 В документации много разных разделов и чтобы разобраться, как создавать простые проекты, по-хорошему **нужно знать порядка 70% от всей документации**. Поэтому, чтобы облегчить вам работу, есть раздел [Знакомство с HollowEngine](/docs/hollowengine/guide), там описано, с чего можно начать знакомство с движком. +Для создания эффектов см. [редактор VFX](/docs/hollowengine/vfx), а для скриптов и файлов проекта - [работу с IDE](/docs/hollowengine/ide). + ## Рабочая папка Практически весь пользовательский контент живёт в директории `hollowengine`, которую мод создаёт в папке с игрой. Внутри неё важнее всего: diff --git a/docs/ru/project-export.mdx b/docs/ru/project-export.mdx index 34b66de74..c9c2f5be1 100644 --- a/docs/ru/project-export.mdx +++ b/docs/ru/project-export.mdx @@ -13,13 +13,13 @@ order: 5 Экспортированный проект остаётся аддоном HollowEngine. Без установленного HollowEngine версии 2.3.3 или новее он не запустится. ::: -## Панель проекта +## Действия проекта -На панели **Проект** появились три действия: +Три действия доступны как кнопки в панели **Проект**, так и в меню **Файл**: 1. **Настройки проекта** - метаданные и зависимости будущего мода. Там же настраиваются зависимые моды и аддоны, которые вы можете использовать в скриптах при разработке. -2. **Импорт проекта** - восстановление ранее экспортированного проекта, если в архив были включены исходники. -3. **Экспорт проекта** - компиляция скриптов и сборка готового `.jar` архив. При необходимости здесь можно отключить экспорт исходного кода проекта, тогда импортировать его обратно будет невозможно. +2. **Импортировать проект** - восстановление ранее экспортированного проекта, если в архив были включены исходники. +3. **Экспортировать как аддон** - компиляция скриптов и сборка готового `.jar`-архива. При необходимости здесь можно отключить экспорт исходного кода проекта, тогда импортировать его обратно будет невозможно. ![Кнопки настроек, импорта и экспорта на панели проекта](/hollowengine/pictures/project-export/actions.webp) @@ -45,7 +45,9 @@ order: 5 ## Экспорт проекта -Нажмите **Экспорт проекта**, затем: +Нажмите кнопку **Экспортировать как аддон** в панели **Проект** или выберите **Файл > Экспортировать как аддон**, затем: + +![Окно экспорта проекта](/hollowengine/pictures/project-export/export-dialog.webp) 1. Проверьте версию будущего мода. 2. Решите, нужно ли включать исходники скриптов. @@ -80,7 +82,7 @@ order: 5 ## Импорт проекта -Чтобы продолжить работу над ранее экспортированным проектом, нажмите **Импорт проекта** и выберите его JAR-файл. +Чтобы продолжить работу над ранее экспортированным проектом, нажмите кнопку **Импортировать проект** в панели **Проект** или выберите **Файл > Импортировать проект**, затем укажите JAR-файл. ![Выбор JAR для импорта](/hollowengine/pictures/project-export/import-dialog.webp) diff --git a/docs/ru/scripting/ui/styles/hss-files.mdx b/docs/ru/scripting/ui/styles/hss-files.mdx index e2c4dedd2..83d4bbae7 100644 --- a/docs/ru/scripting/ui/styles/hss-files.mdx +++ b/docs/ru/scripting/ui/styles/hss-files.mdx @@ -25,6 +25,27 @@ HSS хорошо подходит для палитр, повторяющейс Таблицы стилей живут в файлах: скрипт подключает их по resource location, поэтому оформление экрана остаётся читаемым и перезагружается отдельно от кода. Собрать таблицу стилей из строки прямо в скрипте больше нельзя. +## Импорты и переменные + +Стиль может подключать другие HSS-файлы, хранить значения в переменных `$name` и повторно использовать наборы деклараций через `@apply`: + +```css +@import "project:ui/styles/palette.hss"; + +$accent: #6AA9FF; +$card { + padding: 12px; + border-radius: 6px; +} + +.card { + @apply $card; + background: $accent; +} +``` + +Импорты читаются до правил текущего файла. Локальные переменные и наборы переопределяют одноимённые импортированные; каждый импорт загружается один раз, в том числе при циклических ссылках. Переменные можно использовать и внутри других переменных или наборов. Неизвестное имя и рекурсивная подстановка дают ошибку там, где значение используется. Встроенный редактор HSS показывает диагностику импортов, переменных и `@apply`. + ## Селекторы | Селектор | Что выбирает | diff --git a/docs/ru/vfx.mdx b/docs/ru/vfx.mdx new file mode 100644 index 000000000..8aba8b9a1 --- /dev/null +++ b/docs/ru/vfx.mdx @@ -0,0 +1,65 @@ +--- +title: Визуальные эффекты (VFX) +order: 6 +--- + +

Визуальные эффекты (VFX)

+ +VFX-эффект хранится в файле `.vfx` и собирается из дерева узлов. Один эффект может сочетать частицы, геометрические поверхности, следы, лучи, экранные эффекты, небо и тряску камеры. Встроенный редактор открывает `.vfx` как отдельный документ IDE. + +![Редактор VFX: дерево узлов, предпросмотр, инспектор и таймлайн](/hollowengine/pictures/vfx/editor-overview.webp) + +## Создание и размещение файла + +Создайте пустой файл `hollowengine/assets/project/vfx/sparks.vfx` через пункт **New File** в контекстном меню панели **Проект** или в проводнике. Пустой `.vfx` открывается как новый эффект; редактор сам обновит его при изменении. + +Откройте файл двойным щелчком в дереве проекта. В окне **Сцена** щёлкните правой кнопкой по корню эффекта и выберите **Добавить узел**. Начните с **Emitter**: он создаётся с дочерней плоскостью, которая рисует его частицы. Настраивайте выбранный узел в **Инспекторе**, а ключи анимации в окне **Таймлайн**. Для работы нужны окна **Сцена**, **Инспектор** и **Таймлайн**; каждое можно открыть через меню **Окна**. + +Эффект доступен как ресурс `project:vfx/sparks.vfx`. При запуске можно также написать сокращённое имя `project:sparks`. После изменения ресурса используйте **Файл > Перезагрузить клиентские ресурсы** или `F3 + T`, чтобы обновить эффект в игре. + +## Из чего состоит эффект + +| Узел | Назначение | +|--------------------------------------|-------------------------------------------------------------------------------| +| Group | Объединяет несколько частей эффекта в группу и задаёт им общую трансформацию. | +| Emitter | Создаёт частицы. Дочерние узлы уже настраивают внеший вид этих частиц. | +| Plane, Cube, Sphere, Cylinder, Model | Рисуют поверхности или геометрию частицы. | +| Trail, Beam | Рисуют след или луч. | +| Post Effect, Sky, Camera Shake | Управляют экранным эффектом, небом или тряской камеры. | + +У эмиттера задаются частота и выброс, длительность, форма появления частиц, скорость, время жизни, локальное или мировое пространство симуляции. Дополнительные модули задают силу, шум, столкновения, изменение скорости и анимацию UV. Дочерние рендереры могут иметь разные размеры, поворот, цвет и материал. Описание отдельного параметра появляется при наведении курсора в **Инспекторе**. + +В окне **Сцена** можно добавлять дочерние узлы, переименовывать, дублировать, отключать, удалять и перемещать их. Выбранный узел редактируется в **Инспекторе**; область предпросмотра позволяет вращать камеру, менять масштаб колёсиком и двигать узел инструментами трансформации. Кнопки предпросмотра управляют воспроизведением, сбросом времени, полом и отображением формы эмиттера. Таймлайн хранит дорожки свойств и поддерживает циклическое воспроизведение. + +## Значения, кривые и материалы + +Числовое поле может содержать константу, случайное значение из диапазона, кривую или выражение. Кривая может зависеть от её возраста, возраста эмиттера, времени всего эффекта или скорости. Это позволяет, например, уменьшать размер частицы к концу жизни без ключей на общем таймлайне. Таймлайн служит для анимации параметров самого узла во времени эффекта. + +Выражения читают данные частицы (`p.age`, `p.speed`, `p.progress`), эмиттера (`e.age`, `e.count`), расстояние до камеры (`n.camera_distance`) и значения, переданные запущенному эффекту (`d.name`). Данные передаются через `VfxHandle` из скрипта; отсутствующее числовое значение читается как `0`. Поля материала задают текстуру, освещение и режим смешивания. Для атласа можно указать UV-область, а модуль UV-анимации переключает её кадры. + +## Субэмиттеры + +Поместите один **Emitter** непосредственно внутрь другого и включите **Рождать от частиц родителя** в разделе **Саб-эмиттер**. Дочерний эмиттер будет создавать частицы при выбранном событии родительской частицы вместо собственной частоты и выброса. Его форма появления частиц располагается вокруг места события. В разделе **Испускание** у него остаётся параметр **Предел**, ограничивающий число одновременно живущих частиц. + +Доступны события **Рождение**, **Смерть**, **Столкновение** и **Пока жив**. Для первых трёх **Количество** задаёт число частиц на событие; для **Пока жив** - число частиц в секунду на каждую родительскую частицу. Событие **Столкновение** требует настроенных столкновений у родительского эмиттера. **Инерция** добавляет дочерним частицам указанную долю скорости родителя. + +Например, для фейерверка создайте эмиттер ракет, поместите внутрь него эмиттер вспышек и выберите для вспышек событие **Смерть**. В выражениях полей **Количество** и **Инерция** `p.` относится к родительской частице, вызвавшей событие. В рождённых частицах её случайное число доступно как `p.parent_random` или источник кривой **Случайное число родителя**: так можно согласовать цвет ракеты и вспышки. + +## Мягкие частицы и свечение + +В материале **Plane**, **Cube**, **Sphere**, **Cylinder**, **Model**, **Trail** или **Beam** параметр **Мягкое пересечение** задаёт расстояние в блоках, на котором поверхность плавно исчезает рядом с геометрией сцены. Это помогает избежать резкой границы там, где частица пересекает землю или блок. Значение `0` отключает смягчение. + +**Свечение** создаёт размытый ореол цвета поверхности с учётом глубины сцены. Значение `0` его отключает; увеличение значения усиливает ореол. Аддитивное смешивание меняет цвет самой поверхности и само по себе не включает проход свечения. Оба параметра можно использовать вместе. Вклад в проход свечения не ограничивается единицей: для него используется буфер `RGBA16F`. Для собственного шейдера проход свечения доступен, только если он объявляет `GlowPass` и использует значение `Glow`; мягкое пересечение такой шейдер тоже должен реализовать сам. + +## Запуск в игре + +Команда `/he vfx` принимает позицию или сущность и имя эффекта: + +```mcfunction +/he vfx ~ ~1 ~ project:sparks +/he vfx @s project:sparks +``` + +Из Kotlin-кода эффект можно запустить через `Vfx.play(level, position, "project:sparks")` или `Vfx.play(entity, "project:sparks")`. Оба метода возвращают `VfxHandle`: `stop()` завершает испускание, `stop(immediate = true)` сразу удаляет эффект, а `moveTo(position)` переносит эффект, привязанный к точке. Для эффектов на сущности `moveTo` не действует: они следуют за ней автоматически. + +Имя эффекта соответствует пути в клиентских ресурсах. Если файл отсутствует или не разбирается, проверьте путь и сообщения консоли IDE после перезагрузки ресурсов. diff --git a/gradle.properties b/gradle.properties index e75b0b549..86745799d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.parallel=true # Mod Options modId=hollowengine modName=HollowEngine -modVersion=2.3.5 +modVersion=2.4.0 modAuthor=HollowHorizon license=CC BY-NC-SA 4.0 modGroup=ru.hollowhorizon.hollowengine @@ -15,12 +15,12 @@ resourcePackFormat=34 dataPackFormat=48 enabledPlatforms=fabric,neoforge architecturyPluginVersion=3.5-SNAPSHOT -architecturyLoomVersion=1.14-SNAPSHOT +architecturyLoomVersion=1.17-SNAPSHOT architecturyApiVersion=13.0.8 shadowVersion=8.3.9 parchmentVersion=2024.11.17 -fabricLoaderVersion=0.17.0 -fabricApiVersion=0.116.4+1.21.1 +fabricLoaderVersion=0.19.5 +fabricApiVersion=0.116.17+1.21.1 neoForgeVersion=21.1.220 # Libraries diff --git a/libs/fabric-api-0.116.17+1.21.1.jar b/libs/fabric-api-0.116.17+1.21.1.jar new file mode 100644 index 000000000..16198017b Binary files /dev/null and b/libs/fabric-api-0.116.17+1.21.1.jar differ diff --git a/libs/iris-fabric-1.8.14-beta.1+mc1.21.1-devpatch.jar b/libs/iris-fabric-1.8.14-beta.1+mc1.21.1-devpatch.jar new file mode 100644 index 000000000..2358e84e5 Binary files /dev/null and b/libs/iris-fabric-1.8.14-beta.1+mc1.21.1-devpatch.jar differ diff --git a/libs/iris-fabric-1.8.14-beta.1+mc1.21.1.jar b/libs/iris-fabric-1.8.14-beta.1+mc1.21.1.jar new file mode 100644 index 000000000..730828d42 Binary files /dev/null and b/libs/iris-fabric-1.8.14-beta.1+mc1.21.1.jar differ diff --git a/libs/iris-neoforge-1.8.14-beta.1+mc1.21.1.jar b/libs/iris-neoforge-1.8.14-beta.1+mc1.21.1.jar new file mode 100644 index 000000000..55e2194ee Binary files /dev/null and b/libs/iris-neoforge-1.8.14-beta.1+mc1.21.1.jar differ diff --git a/libs/sodium-fabric-0.8.13+mc1.21.1.jar b/libs/sodium-fabric-0.8.13+mc1.21.1.jar new file mode 100644 index 000000000..ae50bf65c Binary files /dev/null and b/libs/sodium-fabric-0.8.13+mc1.21.1.jar differ diff --git a/libs/sodium-neoforge-0.8.13+mc1.21.1.jar b/libs/sodium-neoforge-0.8.13+mc1.21.1.jar new file mode 100644 index 000000000..9cea5b7f7 Binary files /dev/null and b/libs/sodium-neoforge-0.8.13+mc1.21.1.jar differ diff --git a/runtime/build.gradle.kts b/runtime/build.gradle.kts index 8622b29b6..331048d02 100644 --- a/runtime/build.gradle.kts +++ b/runtime/build.gradle.kts @@ -112,8 +112,8 @@ dependencies { }) modImplementation("net.fabricmc:fabric-loader:$fabricLoaderVersion") - modImplementation("lib:iris-fabric:1.8.8+mc1.21.1") - modImplementation("lib:sodium-fabric:0.6.13+mc1.21.1") + modImplementation("lib:iris-fabric:1.8.14-beta.1+mc1.21.1-devpatch") + modImplementation("lib:sodium-fabric:0.8.13+mc1.21.1") implementation(project(":bridge")) 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 e00a8fd77..d7759ad9f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt @@ -81,6 +81,7 @@ import ru.hollowhorizon.hollowengine.client.ui.script.UiScriptHudHost import ru.hollowhorizon.hollowengine.common.ui.HudPlacement import ru.hollowhorizon.hollowengine.client.editor.WorldInspector import ru.hollowhorizon.hollowengine.client.ui.notification.NotificationOverlay +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxWorldRenderer import ru.hollowhorizon.hollowengine.common.ui.hud.HudLayerRegistry import ru.hollowhorizon.hollowengine.common.ui.hud.VanillaHudLayers import ru.hollowhorizon.hollowengine.client.utils.HollowCoreLoader @@ -499,13 +500,21 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { override fun shouldForceAutoGuiScale(screen: Screen?): Boolean = screen is AutoScaled + override fun onLevelFrameRendered(minecraft: Minecraft) { + RenderTickEvent.LevelRendered.post(RenderTickEvent.LevelRendered(minecraft)) + } + override fun onBeforeBlitScreen(minecraft: Minecraft) { - HollowIdeGameViewport.endRender() + HollowIdeGameViewport.beginWindowPass() } override fun onBlitScreen(minecraft: Minecraft) { HollowIdeGameViewport.restoreWindowViewport() - RenderTickEvent.Blit.post(RenderTickEvent.Blit(minecraft)) + try { + RenderTickEvent.Blit.post(RenderTickEvent.Blit(minecraft)) + } finally { + HollowIdeGameViewport.endWindowPass() + } } override fun onServerCreated(server: MinecraftServer, serverThread: Thread, levelRoot: Path) { @@ -545,11 +554,11 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun onClientTick(client: Minecraft) { + HollowIdeGameViewport.beginFrame(client) RuntimeDispatcherState.runClientTasks(client) } override fun onClientRenderTickPre(client: Minecraft) { - HollowIdeGameViewport.beginRender(client) CutsceneCameraSystem.update(client) RenderTickEvent.Pre.post(RenderTickEvent.Pre(client)) } @@ -558,10 +567,6 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { RenderTickEvent.Post.post(RenderTickEvent.Post(client)) } - override fun onClientResized(client: Minecraft) { - HollowIdeGameViewport.invalidateWindowMetrics() - } - override fun onClientStopping(client: Minecraft) { RuntimeDispatcherState.stopClient(client) } @@ -794,6 +799,10 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { IrisHelper.invalidateInstancingPrograms() } + override fun onIrisLevelFinished() { + VfxWorldRenderer.onShaderPackFrameFinished() + } + override fun onIrisShadowRenderStart() { InstanceBatchManager.clear() } @@ -957,7 +966,7 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { override fun getGameViewportMetrics(): RuntimeBridge.GameViewportMetrics? = HollowIdeGameViewport.metrics() - override fun isGameViewportRendering(): Boolean = HollowIdeGameViewport.isRendering() + override fun isGameViewportWindowPass(): Boolean = HollowIdeGameViewport.isWindowPass() override fun onRenderLevelStage( renderer: LevelRenderer, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HollowCoreClient.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HollowCoreClient.kt index b629bd2d1..abc8925bd 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HollowCoreClient.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/HollowCoreClient.kt @@ -6,6 +6,8 @@ import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.models.internal.manager.HollowModelManager import ru.hollowhorizon.hollowengine.client.particles.BedrockParticles import ru.hollowhorizon.hollowengine.client.render.RenderManager +import ru.hollowhorizon.hollowengine.client.vfx.VfxAssets +import ru.hollowhorizon.hollowengine.client.vfx.VfxBoneBindings import ru.hollowhorizon.hollowengine.client.render.entity.EmptyEntityRenderer import ru.hollowhorizon.hollowengine.client.ui.render.UiPathTileResources import ru.hollowhorizon.hollowengine.client.ui.screen.HollowUiDemoScreen @@ -22,18 +24,22 @@ import ru.hollowhorizon.hollowengine.common.events.registry.RegisterResourcePack import ru.hollowhorizon.hollowengine.common.events.tick.TickEvent import ru.hollowhorizon.hollowengine.common.registry.ModEntities import ru.hollowhorizon.hollowengine.common.utils.ModList +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeReloadListener @ClientOnly object HollowCoreClient { init { RenderSystem.recordRenderCall(RenderManager::onInitialize) + VfxBoneBindings.register() } @SubscribeEvent fun onRegisterReloadListener(event: RegisterReloadListenersEvent.Client) { event.register(HollowModelManager) event.register(BedrockParticles) + event.register(ShaderNodeReloadListener) + event.register(VfxAssets) event.register(UiPathTileResources) event.register(UiFontResources) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoGeometry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoGeometry.kt index ef69a1bed..37bb0220a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoGeometry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoGeometry.kt @@ -77,31 +77,8 @@ object GizmoColors { /** * Builds the screen-space geometry for the gizmo. */ -object GizmoGeometry { - private const val AXIS_LENGTH_PX = 38f - private const val PLANE_OFFSET = 0.36f - private const val PLANE_HALF = 0.17f - private const val RING_RADIUS_PX = 36f - private const val RING_SEGMENTS = 72 - private const val CENTER_RADIUS_PX = 7f - private const val SCALE_CUBE_PX = 4.5f - private const val CIRCLE_SEGMENTS = 48 - private const val CONE_LENGTH_PX = 10f - private const val CONE_RADIUS_PX = 3.6f - private const val CONE_SEGMENTS = 16 - - private const val COMBINED_RING_RADIUS_PX = 52f - private const val COMBINED_SCALE_LENGTH_PX = 24f - - private const val AWAY_DIMMING = 0.6f - private const val RING_BACK_EMPHASIS = 0.3f - private const val EDGE_ON_PLANE_EMPHASIS = 0.35f - - private const val AXIS_WIDTH = 1.7f - private const val RING_WIDTH = 1.7f - +class GizmoGeometry(private val projector: GizmoProjector) { fun buildHandles(translation: Vec3f, rotation: QuatF, modes: Set): List { - val projector = WorldToScreenProjector val origin = Vec3(translation.x.toDouble(), translation.y.toDouble(), translation.z.toDouble()) val perPixel = projector.worldPerPixel(origin) val originScreen = projector.project(origin) ?: return emptyList() @@ -135,7 +112,7 @@ object GizmoGeometry { } if (GizmoEditMode.SCALE in modes) { - // Next to the arrows the scale handles lose their shafts and the centre stays free movement. + // Next to the arrows the scale handles lose their shafts and the center stays free movement. val basis = Triple(lx, ly, lz) scaleAxis(GizmoHandleId.SCALE_X, origin, lx, basis, perPixel, translate, GizmoColors.AXIS_X)?.let(::add) scaleAxis(GizmoHandleId.SCALE_Y, origin, ly, basis, perPixel, translate, GizmoColors.AXIS_Y)?.let(::add) @@ -149,7 +126,6 @@ object GizmoGeometry { } private fun axisArrow(id: GizmoHandleId, origin: Vec3, axis: Vec3f, perPixel: Float, color: UiColor): GizmoHandle? { - val projector = WorldToScreenProjector val direction = worldVec(axis) val length = (perPixel * AXIS_LENGTH_PX).toDouble() val tip = origin.add(direction.scale(length)) @@ -171,7 +147,6 @@ object GizmoGeometry { } private fun cone(tip: Vec3, axis: Vec3, perPixel: Float): List? { - val projector = WorldToScreenProjector val base = tip.subtract(axis.scale((perPixel * CONE_LENGTH_PX).toDouble())) val radius = (perPixel * CONE_RADIUS_PX).toDouble() val u = perpendicular(axis) @@ -188,7 +163,6 @@ object GizmoGeometry { } private fun planeQuad(id: GizmoHandleId, origin: Vec3, normal: Vec3f, spanA: Vec3f, spanB: Vec3f, perPixel: Float, color: UiColor): GizmoHandle? { - val projector = WorldToScreenProjector val length = perPixel * AXIS_LENGTH_PX val offset = length * PLANE_OFFSET val half = length * PLANE_HALF @@ -231,7 +205,6 @@ object GizmoGeometry { radiusPx: Float, color: UiColor, ): GizmoHandle? { - val projector = WorldToScreenProjector val radius = perPixel * radiusPx val worldPts = ArrayList(RING_SEGMENTS + 1) val pts = ArrayList(RING_SEGMENTS + 1) @@ -297,7 +270,6 @@ object GizmoGeometry { besideArrows: Boolean, color: UiColor, ): GizmoHandle? { - val projector = WorldToScreenProjector val direction = worldVec(axis) val length = perPixel * if (besideArrows) COMBINED_SCALE_LENGTH_PX else AXIS_LENGTH_PX val tipWorld = origin.add(direction.scale(length.toDouble())) @@ -327,7 +299,7 @@ object GizmoGeometry { color: UiColor, ): GizmoHandle? { val cube = projectedCube(origin, basis, half) ?: return null - val depth = WorldToScreenProjector.project(origin)?.depth ?: return null + val depth = projector.project(origin)?.depth ?: return null return GizmoHandle( id, origin, worldAxis = null, listOf(GizmoStroke(cube, closed = true)), fillPolygon = cube, color = color, width = RING_WIDTH, depth = depth, pick = PickPrimitive.Polygon(cube), @@ -335,7 +307,6 @@ object GizmoGeometry { } private fun projectedCube(center: Vec3, basis: Triple, half: Float): List? { - val projector = WorldToScreenProjector val (a, b, c) = basis val points = ArrayList(8) for (sa in SIGNS) for (sb in SIGNS) for (sc in SIGNS) { @@ -361,7 +332,7 @@ object GizmoGeometry { /** Cosine between [direction] and the direction from [origin] towards the camera. */ private fun viewFacing(origin: Vec3, direction: Vec3): Float { - val toCamera = WorldToScreenProjector.cameraPosition.subtract(origin) + val toCamera = projector.cameraPosition.subtract(origin) val length = toCamera.length() * direction.length() if (length < 1e-9) return 1f return (direction.dot(toCamera) / length).toFloat() @@ -404,7 +375,6 @@ object GizmoGeometry { * [axis], for the rotation drag readout. Basis matches [GizmoManipulator.anglePlane]. */ fun buildRotationSector(origin: Vec3, axis: Vec3, startAngle: Double, endAngle: Double, perPixel: Float): List? { - val projector = WorldToScreenProjector val radius = perPixel * RING_RADIUS_PX * 0.92f val u = perpendicular(axis) val v = axis.cross(u) @@ -434,7 +404,6 @@ object GizmoGeometry { /** The 12 edges of [bounds] projected into screen space, or empty when any corner is off-screen. */ fun buildBoundsEdges(bounds: AABB): List> { - val projector = WorldToScreenProjector val corners = arrayOf( Vec3(bounds.minX, bounds.minY, bounds.minZ), Vec3(bounds.maxX, bounds.minY, bounds.minZ), Vec3(bounds.maxX, bounds.minY, bounds.maxZ), Vec3(bounds.minX, bounds.minY, bounds.maxZ), @@ -463,7 +432,6 @@ object GizmoGeometry { /** Projects a world-space polyline; returns null if any vertex is off-screen. */ fun projectPolyline(points: List): List? { - val projector = WorldToScreenProjector val result = ArrayList(points.size) for (point in points) { val p = projector.project(point) ?: return null @@ -472,4 +440,28 @@ object GizmoGeometry { } return result } + + companion object { + /** The one the world overlay uses. */ + val World = GizmoGeometry(WorldToScreenProjector) + + private const val AXIS_LENGTH_PX = 38f + private const val PLANE_OFFSET = 0.36f + private const val PLANE_HALF = 0.17f + private const val RING_RADIUS_PX = 36f + private const val RING_SEGMENTS = 72 + private const val CENTER_RADIUS_PX = 7f + private const val SCALE_CUBE_PX = 4.5f + private const val CIRCLE_SEGMENTS = 48 + private const val CONE_LENGTH_PX = 10f + private const val CONE_RADIUS_PX = 3.6f + private const val CONE_SEGMENTS = 16 + private const val COMBINED_RING_RADIUS_PX = 52f + private const val COMBINED_SCALE_LENGTH_PX = 24f + private const val AWAY_DIMMING = 0.6f + private const val RING_BACK_EMPHASIS = 0.3f + private const val EDGE_ON_PLANE_EMPHASIS = 0.35f + private const val AXIS_WIDTH = 1.7f + private const val RING_WIDTH = 1.7f + } } 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 156e08ca0..413c896ef 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 @@ -48,11 +48,11 @@ class GizmoDrag internal constructor( /** * Screen-to-world manipulation math for the gizmo. */ -object GizmoManipulator { +class GizmoManipulator(private val projector: GizmoProjector) { 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) - val ray = WorldToScreenProjector.screenRay(pointerX, pointerY) + val ray = projector.screenRay(pointerX, pointerY) when (handle.id) { GizmoHandleId.AXIS_X, GizmoHandleId.AXIS_Y, GizmoHandleId.AXIS_Z, GizmoHandleId.SCALE_X, GizmoHandleId.SCALE_Y, GizmoHandleId.SCALE_Z -> { @@ -87,7 +87,7 @@ object GizmoManipulator { } GizmoHandleId.CENTER -> { - val toCamera = WorldToScreenProjector.cameraPosition.subtract(handle.worldOrigin) + val toCamera = projector.cameraPosition.subtract(handle.worldOrigin) val length = toCamera.length() if (ray != null && length > 1e-6) { val normal = toCamera.scale(1.0 / length) @@ -101,7 +101,7 @@ object GizmoManipulator { } GizmoHandleId.SCALE_UNIFORM -> { - val originScreen = WorldToScreenProjector.project(handle.worldOrigin) + val originScreen = projector.project(handle.worldOrigin) if (originScreen != null) { drag.referenceDistance = distance(originScreen.x, originScreen.y, pointerX, pointerY) drag.valid = drag.referenceDistance > 1e-3f @@ -147,7 +147,7 @@ object GizmoManipulator { private fun updateViewPlane(drag: GizmoDrag, x: Float, y: Float, speed: Double, snap: Boolean, fine: Boolean): GizmoTransformValues? { val normal = drag.planeNormal ?: return null val reference = drag.referenceHit ?: return null - val ray = WorldToScreenProjector.screenRay(x, y) ?: return null + val ray = projector.screenRay(x, y) ?: return null val hit = rayPlane(ray, drag.origin, normal) ?: return null var dx = (hit.x - reference.x) * speed var dy = (hit.y - reference.y) * speed @@ -164,7 +164,7 @@ object GizmoManipulator { private fun updateAxis(drag: GizmoDrag, x: Float, y: Float, speed: Double, snap: Boolean, fine: Boolean): GizmoTransformValues? { val axis = drag.axis ?: return null - val ray = WorldToScreenProjector.screenRay(x, y) ?: return null + val ray = projector.screenRay(x, y) ?: return null val t = closestParamOnAxis(ray, drag.origin, axis) ?: return null var delta = (t - drag.referenceParam) * speed if (snap) delta = round(delta / translationTick(fine)) * translationTick(fine) @@ -176,7 +176,7 @@ object GizmoManipulator { private fun updatePlane(drag: GizmoDrag, x: Float, y: Float, speed: Double, snap: Boolean, fine: Boolean): GizmoTransformValues? { val axis = drag.axis ?: return null val reference = drag.referenceHit ?: return null - val ray = WorldToScreenProjector.screenRay(x, y) ?: return null + val ray = projector.screenRay(x, y) ?: return null val hit = rayPlane(ray, drag.origin, axis) ?: return null var dx = (hit.x - reference.x) * speed var dy = (hit.y - reference.y) * speed @@ -194,7 +194,7 @@ object GizmoManipulator { private fun updateRotate(drag: GizmoDrag, x: Float, y: Float, speed: Double, snap: Boolean, fine: Boolean): GizmoTransformValues? { val axis = drag.axis ?: return null - val ray = WorldToScreenProjector.screenRay(x, y) ?: return null + val ray = projector.screenRay(x, y) ?: return null val angle = anglePlane(ray, drag.origin, axis) ?: return null drag.currentAngle = drag.referenceAngle + shortestAngle(angle - drag.referenceAngle) var deltaDeg = Math.toDegrees(shortestAngle(angle - drag.referenceAngle)) * speed @@ -207,7 +207,7 @@ object GizmoManipulator { } private fun updateScale(drag: GizmoDrag, x: Float, y: Float, speed: Double, snap: Boolean, fine: Boolean): GizmoTransformValues? { - val originScreen = WorldToScreenProjector.project(drag.origin) ?: return null + val originScreen = projector.project(drag.origin) ?: return null val current = distance(originScreen.x, originScreen.y, x, y) if (drag.referenceDistance <= 1e-3f) return null var factor = (current / drag.referenceDistance).toDouble() @@ -226,7 +226,7 @@ object GizmoManipulator { private fun updateScaleAxis(drag: GizmoDrag, x: Float, y: Float, speed: Double, snap: Boolean, fine: Boolean): GizmoTransformValues? { val axis = drag.axis ?: return null if (abs(drag.referenceParam) < 1e-4) return null - val ray = WorldToScreenProjector.screenRay(x, y) ?: return null + val ray = projector.screenRay(x, y) ?: return null val t = closestParamOnAxis(ray, drag.origin, axis) ?: return null var factor = t / drag.referenceParam factor = 1.0 + (factor - 1.0) * speed @@ -294,4 +294,9 @@ object GizmoManipulator { private fun translationTick(fine: Boolean) = if (fine) 0.1 else 1.0 private fun rotationTick(fine: Boolean) = if (fine) 1.0 else 5.0 private fun scaleTick(fine: Boolean) = if (fine) 0.01 else 0.1 + + companion object { + /** The one the world overlay uses. */ + val World = GizmoManipulator(WorldToScreenProjector) + } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoRenderer.kt new file mode 100644 index 000000000..fe2b7ebec --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/GizmoRenderer.kt @@ -0,0 +1,109 @@ +package ru.hollowhorizon.hollowengine.client.editor + +import ru.hollowhorizon.hollowengine.client.ui.UiCanvasDrawScope +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.UiDrawStyle +import ru.hollowhorizon.hollowengine.client.ui.shape.GenericShape +import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint + +/** + * Draws gizmo geometry onto a canvas, the same way wherever the gizmo is shown: over the world, or + * over a preview panel. + */ +object GizmoRenderer { + /** Screen span beyond which a polyline is treated as degenerate and skipped (guards the tiler). */ + private const val MAX_DRAW_SPAN = 8000f + + private val SectorFill = UiColor(1f, 0.85f, 0.32f, 0.25f) + private val SectorStroke = UiColor(1f, 0.86f, 0.34f, 0.9f) + + /** [handles] far to near; the one being dragged, or else the one under the pointer, lit up. */ + fun drawHandles( + scope: UiCanvasDrawScope, + handles: List, + hovered: GizmoHandleId?, + dragging: GizmoHandleId?, + ) { + for (handle in handles.sortedByDescending { it.depth }) { + val highlighted = handle.id == dragging || (dragging == null && handle.id == hovered) + val base = if (highlighted) GizmoColors.highlighted(handle.color) else handle.color + val emphasis = if (highlighted) 1f else handle.emphasis + handle.fillPolygon?.let { fill -> + val alpha = (if (handle.id.isSolidHandle()) 0.72f else 0.22f) * emphasis + fillPolygon(scope, fill, base.withAlpha(alpha)) + } + for (stroke in handle.renderLines) { + val strokeEmphasis = if (highlighted) 1f else emphasis * stroke.emphasis + strokeLine( + scope, + stroke.points, + base.withAlpha(base.alpha * strokeEmphasis), + handle.width * (0.55f + 0.45f * strokeEmphasis), + stroke.closed, + ) + } + } + } + + /** The angle a rotation drag has swept so far, as a filled sector on its ring. */ + fun drawRotationSector(scope: UiCanvasDrawScope, geometry: GizmoGeometry, projector: GizmoProjector, drag: GizmoDrag) { + if (!drag.handleId.isRotation()) return + val axis = drag.axis ?: return + val perPixel = projector.worldPerPixel(drag.origin) + val sector = geometry.buildRotationSector(drag.origin, axis, drag.startAngle, drag.angle, perPixel) ?: return + fillPolygon(scope, sector, SectorFill) + strokeLine(scope, sector, SectorStroke, 1.5f, closed = true) + } + + fun strokeLine( + scope: UiCanvasDrawScope, + points: List, + color: UiColor, + width: Float, + closed: Boolean = false, + ) { + if (points.size < 2 || !withinDrawBounds(points)) return + val shape = GenericShape { + points.forEachIndexed { index, point -> + if (index == 0) moveTo(point.x, point.y) else lineTo(point.x, point.y) + } + if (closed) close() + } + scope.drawShape(shape, UiPaint.Color(color), UiDrawStyle.Stroke(width)) + } + + fun fillPolygon(scope: UiCanvasDrawScope, points: List, color: UiColor) { + if (points.size < 3 || !withinDrawBounds(points)) return + val shape = GenericShape { + points.forEachIndexed { index, point -> + if (index == 0) moveTo(point.x, point.y) else lineTo(point.x, point.y) + } + close() + } + scope.drawShape(shape, UiPaint.Color(color), UiDrawStyle.Fill) + } + + private fun withinDrawBounds(points: List): Boolean { + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + for (point in points) { + if (!point.x.isFinite() || !point.y.isFinite()) return false + minX = minOf(minX, point.x); minY = minOf(minY, point.y) + maxX = maxOf(maxX, point.x); maxY = maxOf(maxY, point.y) + } + return (maxX - minX) <= MAX_DRAW_SPAN && (maxY - minY) <= MAX_DRAW_SPAN + } + + private fun UiColor.withAlpha(alpha: Float) = UiColor(red, green, blue, alpha) + + private fun GizmoHandleId.isSolidHandle(): Boolean = when (this) { + GizmoHandleId.AXIS_X, GizmoHandleId.AXIS_Y, GizmoHandleId.AXIS_Z, + GizmoHandleId.SCALE_X, GizmoHandleId.SCALE_Y, GizmoHandleId.SCALE_Z, GizmoHandleId.SCALE_UNIFORM -> true + else -> false + } +} + +fun GizmoHandleId.isRotation(): Boolean = + this == GizmoHandleId.ROTATE_X || this == GizmoHandleId.ROTATE_Y || this == GizmoHandleId.ROTATE_Z diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/TransformGizmoEditor.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/TransformGizmoEditor.kt index 958c8a01f..f70be6d69 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/TransformGizmoEditor.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/TransformGizmoEditor.kt @@ -26,8 +26,6 @@ import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlay import ru.hollowhorizon.hollowengine.client.ui.ide.hollowIdeModifierMask import ru.hollowhorizon.hollowengine.client.ui.ide.hollowIdeWorldPoint 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.style.UiShadow import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem @@ -182,7 +180,7 @@ object TransformGizmoEditor { val entry = draggingKey?.let(entries::get) if (drag != null && entry != null) { val (cx, cy) = WorldToScreenProjector.screenCenter() - GizmoManipulator.update(drag, cx, cy, hollowIdeModifierMask())?.let { values -> + GizmoManipulator.World.update(drag, cx, cy, hollowIdeModifierMask())?.let { values -> entry.working = values applyFromGizmo(entry, values) updateLabel(entry, drag) @@ -248,7 +246,7 @@ object TransformGizmoEditor { } if (drag != null) { val entry = draggingKey?.let(entries::get) - val values = GizmoManipulator.update(drag, x, y, hollowIdeModifierMask()) + val values = GizmoManipulator.World.update(drag, x, y, hollowIdeModifierMask()) if (values != null && entry != null) { entry.working = values applyFromGizmo(entry, values) @@ -293,7 +291,7 @@ object TransformGizmoEditor { val entry = activeKey?.let(entries::get) ?: return false when (button) { GLFW.GLFW_MOUSE_BUTTON_LEFT -> { - currentDrag = GizmoManipulator.begin(handle, entry.working ?: return false, x, y) + currentDrag = GizmoManipulator.World.begin(handle, entry.working ?: return false, x, y) draggingKey = entry.entryId draggingHandleId = handle.id contextMenuState = null @@ -351,7 +349,7 @@ object TransformGizmoEditor { private fun activeEntryHandleAt(x: Float, y: Float): GizmoHandle? { val entry = activeKey?.let(entries::get)?.takeIf { it.visible } ?: return null val working = entry.working ?: return null - val handles = GizmoGeometry.buildHandles(working.translation, working.rotation, modes) + val handles = GizmoGeometry.World.buildHandles(working.translation, working.rotation, modes) return GizmoPicker.pick(handles, x, y) } @@ -442,7 +440,7 @@ object TransformGizmoEditor { } entry.lastBounds?.let { bounds -> val width = boundsLineWidth(bounds) - for (edge in GizmoGeometry.buildBoundsEdges(bounds)) strokeLine(scope, edge, boundsColor, width) + for (edge in GizmoGeometry.World.buildBoundsEdges(bounds)) GizmoRenderer.strokeLine(scope, edge, boundsColor, width) } } @@ -450,50 +448,12 @@ object TransformGizmoEditor { val working = active.working ?: return val drag = currentDrag - if (drag != null && drag.handleId.isRotation() && draggingKey == active.entryId) { - drag.axis?.let { axis -> - val perPixel = WorldToScreenProjector.worldPerPixel(drag.origin) - GizmoGeometry.buildRotationSector(drag.origin, axis, drag.startAngle, drag.angle, perPixel) - ?.let { sector -> - fillPolygon(scope, sector, UiColor(1f, 0.85f, 0.32f, 0.25f)) - strokeLine(scope, sector, UiColor(1f, 0.86f, 0.34f, 0.9f), 1.5f, closed = true) - } - } - } - - val handles = GizmoGeometry.buildHandles(working.translation, working.rotation, modes) - .sortedByDescending { it.depth } - for (handle in handles) { - val highlighted = - handle.id == draggingHandleId || (draggingHandleId == null && handle.id == hoveredHandleId) - val base = if (highlighted) GizmoColors.highlighted(handle.color) else handle.color - val emphasis = if (highlighted) 1f else handle.emphasis - handle.fillPolygon?.let { fill -> - val alpha = (if (handle.id.isSolidHandle()) 0.72f else 0.22f) * emphasis - fillPolygon(scope, fill, base.withAlpha(alpha)) - } - for (stroke in handle.renderLines) { - val strokeEmphasis = if (highlighted) 1f else emphasis * stroke.emphasis - strokeLine( - scope, - stroke.points, - base.withAlpha(base.alpha * strokeEmphasis), - handle.width * (0.55f + 0.45f * strokeEmphasis), - stroke.closed, - ) - } + if (drag != null && draggingKey == active.entryId) { + GizmoRenderer.drawRotationSector(scope, GizmoGeometry.World, WorldToScreenProjector, drag) } - } - - private fun UiColor.withAlpha(alpha: Float) = UiColor(red, green, blue, alpha) - - private fun GizmoHandleId.isRotation(): Boolean = - this == GizmoHandleId.ROTATE_X || this == GizmoHandleId.ROTATE_Y || this == GizmoHandleId.ROTATE_Z - private fun GizmoHandleId.isSolidHandle(): Boolean = when (this) { - GizmoHandleId.AXIS_X, GizmoHandleId.AXIS_Y, GizmoHandleId.AXIS_Z, - GizmoHandleId.SCALE_X, GizmoHandleId.SCALE_Y, GizmoHandleId.SCALE_Z, GizmoHandleId.SCALE_UNIFORM -> true - else -> false + val handles = GizmoGeometry.World.buildHandles(working.translation, working.rotation, modes) + GizmoRenderer.drawHandles(scope, handles, hoveredHandleId, draggingHandleId) } /** Thinner bounding-box lines when the box is small/distant on screen. */ @@ -522,50 +482,6 @@ object TransformGizmoEditor { } - /** Screen span beyond which a polyline is treated as degenerate and skipped (guards the tiler). */ - private const val MAX_DRAW_SPAN = 8000f - - private fun withinDrawBounds(points: List): Boolean { - var minX = Float.POSITIVE_INFINITY - var minY = Float.POSITIVE_INFINITY - var maxX = Float.NEGATIVE_INFINITY - var maxY = Float.NEGATIVE_INFINITY - for (point in points) { - if (!point.x.isFinite() || !point.y.isFinite()) return false - minX = minOf(minX, point.x); minY = minOf(minY, point.y) - maxX = maxOf(maxX, point.x); maxY = maxOf(maxY, point.y) - } - return (maxX - minX) <= MAX_DRAW_SPAN && (maxY - minY) <= MAX_DRAW_SPAN - } - - private fun strokeLine( - scope: UiCanvasDrawScope, - points: List, - color: UiColor, - width: Float, - closed: Boolean = false, - ) { - if (points.size < 2 || !withinDrawBounds(points)) return - val shape = GenericShape { - points.forEachIndexed { index, point -> - if (index == 0) moveTo(point.x, point.y) else lineTo(point.x, point.y) - } - if (closed) close() - } - scope.drawShape(shape, UiPaint.Color(color), UiDrawStyle.Stroke(width)) - } - - private fun fillPolygon(scope: UiCanvasDrawScope, points: List, color: UiColor) { - if (points.size < 3 || !withinDrawBounds(points)) return - val shape = GenericShape { - points.forEachIndexed { index, point -> - if (index == 0) moveTo(point.x, point.y) else lineTo(point.x, point.y) - } - close() - } - scope.drawShape(shape, UiPaint.Color(color), UiDrawStyle.Fill) - } - private fun updateLabel(entry: GizmoEntry, drag: GizmoDrag) { val origin = entry.working?.translation ?: return val screen = WorldToScreenProjector.project( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldInspector.kt index 5832440d0..d0a686674 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldInspector.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldInspector.kt @@ -10,6 +10,7 @@ import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.entity.* import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlay import ru.hollowhorizon.hollowengine.client.ui.ide.hollowIdeWorldPoint +import ru.hollowhorizon.hollowengine.client.ui.inspector.AssetPickerDialog import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorSelection import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget import ru.hollowhorizon.hollowengine.client.ui.inspector.LocalInspectorHost @@ -144,7 +145,7 @@ object WorldInspector { LocalInspectorHost provides session, ) { EntitySidebar(session, width = null) - session.pendingPicker?.let { picker -> AssetPickerDialog(picker) } + session.pendingPicker?.let { picker -> AssetPickerDialog(picker) { session.pendingPicker = null } } if (session.slotSessionId != null) InventoryDialog(session) } } @@ -171,7 +172,7 @@ object WorldInspector { private fun outline(scope: UiCanvasDrawScope, entity: Entity, color: UiColor) { val bounds = entity.boundingBox.inflate(PickSlack) - for (edge in GizmoGeometry.buildBoundsEdges(bounds)) { + for (edge in GizmoGeometry.World.buildBoundsEdges(bounds)) { if (edge.size < 2) continue val shape = GenericShape { edge.forEachIndexed { index, point -> diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldToScreenProjector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldToScreenProjector.kt index 065cfd1dd..0dd1434d8 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldToScreenProjector.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/editor/WorldToScreenProjector.kt @@ -7,6 +7,8 @@ import org.joml.Vector4f import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeScale import kotlin.math.tan +private const val NEAR_PLANE_EPSILON = 0.05f + /** * A world point projected into the HollowUi overlay's logical coordinate space. */ @@ -23,7 +25,12 @@ data class WorldRay( val direction: Vec3, ) -object WorldToScreenProjector { +/** + * Projects points of one camera into the logical pixels of the surface it is shown on, and back into + * rays. The world has one ([WorldToScreenProjector]); a preview panel that draws the same gizmo keeps + * its own, captured from its own camera and sized to the panel. + */ +open class GizmoProjector { private val combined = Matrix4f() private val inverse = Matrix4f() private var camX = 0.0 @@ -33,21 +40,29 @@ object WorldToScreenProjector { private var logicalWidth = 1f private var logicalHeight = 1f - private const val NEAR_PLANE_EPSILON = 0.05f private val scratch = Vector4f() - fun capture(view: Matrix4f, projection: Matrix4f, cameraPosition: Vec3, fovDegrees: Float) { + /** + * Takes the camera of the next frame. [view] is the rotation of the camera alone: points are made + * relative to [cameraPosition] before it applies, which keeps world coordinates precise. + */ + fun capture( + view: Matrix4f, + projection: Matrix4f, + cameraPosition: Vec3, + fovDegrees: Float, + width: Float, + height: Float, + ) { combined.set(projection).mul(view) combined.invert(inverse) camX = cameraPosition.x camY = cameraPosition.y camZ = cameraPosition.z fovYRadians = Math.toRadians(fovDegrees.toDouble()).toFloat() - val target = Minecraft.getInstance().mainRenderTarget - val scale = HollowIdeScale.factor() - logicalWidth = (target.width / scale).coerceAtLeast(1f) - logicalHeight = (target.height / scale).coerceAtLeast(1f) + logicalWidth = width.coerceAtLeast(1f) + logicalHeight = height.coerceAtLeast(1f) } val width: Float get() = logicalWidth @@ -102,3 +117,12 @@ object WorldToScreenProjector { return (worldHeightAtDepth / logicalHeight).coerceAtLeast(1.0e-5f) } } + +/** The projector of the game camera, in the logical pixels of the IDE overlay. */ +object WorldToScreenProjector : GizmoProjector() { + fun capture(view: Matrix4f, projection: Matrix4f, cameraPosition: Vec3, fovDegrees: Float) { + val target = Minecraft.getInstance().mainRenderTarget + val scale = HollowIdeScale.factor() + capture(view, projection, cameraPosition, fovDegrees, target.width / scale, target.height / scale) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/fbx/fbxBinaryTokenizer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/fbx/fbxBinaryTokenizer.kt index 0d9a3c821..3ceb623c5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/fbx/fbxBinaryTokenizer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/fbx/fbxBinaryTokenizer.kt @@ -155,7 +155,7 @@ fun readScope(outputTokens: ArrayList, input: ByteBuffer, end: Int, is64b private var sBeg = -1 private var sEnd = -1 -// TODO: Test FBX Binary files newer than the 7500 version to check if the 64 bits address behaviour is consistent +// TODO: Test FBX Binary files newer than the 7500 version to check if the 64 bits address behavior is consistent fun tokenizeBinary(outputTokens: ArrayList, input: ByteBuffer) { if (input.rem < 0x1b) tokenizeError("file is too short", 0) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/Material.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/Material.kt index aebdc4d2d..90cd3d499 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/Material.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/Material.kt @@ -1,7 +1,15 @@ package ru.hollowhorizon.hollowengine.client.models.internal +import com.mojang.blaze3d.systems.RenderSystem +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance import net.minecraft.resources.ResourceLocation +import org.lwjgl.opengl.GL33 import ru.hollowhorizon.hollowengine.HollowEngine.MODID +import ru.hollowhorizon.hollowengine.client.models.internal.rendering.MaterialBindContext +import ru.hollowhorizon.hollowengine.client.models.internal.rendering.RenderMaterial +import ru.hollowhorizon.hollowengine.client.utils.areShadersEnabled +import ru.hollowhorizon.hollowengine.client.utils.toTexture import ru.hollowhorizon.hollowengine.common.utils.Color import ru.hollowhorizon.hollowengine.common.utils.rl @@ -20,12 +28,49 @@ data class Material( var doubleSided: Boolean = false, var blend: Blend = Blend.OPAQUE, var emissive: Boolean = false, -) { +) : RenderMaterial { enum class Blend { OPAQUE, BLEND } + override val isTranslucent: Boolean get() = blend == Blend.BLEND + + override fun bind(context: MaterialBindContext) { + val shader = context.shader + if (context.colorLocation != -1) { + GL33.glVertexAttrib4f(context.colorLocation, color.r, color.g, color.b, color.a) + } + + if (areShadersEnabled) { + bindExtraMap(shader, "normals", normalTexture) + bindExtraMap(shader, "specular", specularTexture) + } + + RenderSystem.activeTexture(COLOR_MAP_INDEX) + RenderSystem.bindTexture(Minecraft.getInstance().textureManager.getTexture(texture).id) + + if (doubleSided) RenderSystem.disableCull() else RenderSystem.enableCull() + + when (blend) { + Blend.OPAQUE -> RenderSystem.disableBlend() + Blend.BLEND -> { + RenderSystem.enableBlend() + RenderSystem.defaultBlendFunc() + } + } + } + + override fun clear(context: MaterialBindContext) = Unit + + private fun bindExtraMap(shader: ShaderInstance, uniform: String, map: ResourceLocation) { + val location = GL33.glGetUniformLocation(shader.id, uniform) + if (location == -1) return + + RenderSystem.activeTexture(COLOR_MAP_INDEX + GL33.glGetUniformi(shader.id, location)) + RenderSystem.bindTexture(map.toTexture().id) + } + companion object { val MISSING_TEXTURE = "$MODID:default_color_map".rl val MISSING_NORMAL = "$MODID:default_normal_map".rl val MISSING_SPECULAR = "$MODID:default_specular_map".rl } -} \ No newline at end of file +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt index bb76e6959..77536e298 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/PipelineRenderer.kt @@ -589,39 +589,7 @@ class PipelineRenderer(private val primitive: Primitive) : MeshRenderer { } private fun applyMaterial(shader: ShaderInstance, material: Material, colorLocation: Int = 1) { - if (colorLocation != -1) { - GL33.glVertexAttrib4f(colorLocation, material.color.r, material.color.g, material.color.b, material.color.a) - } - - var normal = 0 - var specular = 0 - - if (areShadersEnabled) { - GL33.glGetUniformLocation(shader.id, "normals").takeIf { it != -1 }?.let { - RenderSystem.activeTexture(COLOR_MAP_INDEX + GL33.glGetUniformi(shader.id, it)) - normal = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) - RenderSystem.bindTexture(material.normalTexture.toTexture().id) - } - GL33.glGetUniformLocation(shader.id, "specular").takeIf { it != -1 }?.let { - RenderSystem.activeTexture(COLOR_MAP_INDEX + GL33.glGetUniformi(shader.id, it)) - specular = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) - RenderSystem.bindTexture(material.specularTexture.toTexture().id) - } - } - - RenderSystem.activeTexture(COLOR_MAP_INDEX) - RenderSystem.bindTexture(Minecraft.getInstance().textureManager.getTexture(material.texture).id) - - if (material.doubleSided) RenderSystem.disableCull() - else RenderSystem.enableCull() - - when (material.blend) { - Material.Blend.OPAQUE -> RenderSystem.disableBlend() - Material.Blend.BLEND -> { - RenderSystem.enableBlend() - RenderSystem.defaultBlendFunc() - } - } + material.bind(MaterialBindContext(shader, colorLocation)) } override fun destroy() { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderMaterial.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderMaterial.kt new file mode 100644 index 000000000..9cf7963c4 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/models/internal/rendering/RenderMaterial.kt @@ -0,0 +1,25 @@ +package ru.hollowhorizon.hollowengine.client.models.internal.rendering + +import net.minecraft.client.renderer.ShaderInstance + +/** + * The appearance of the surface to be rendered. + */ +interface RenderMaterial { + /** + * Should the rendering calls for this material be sorted from far to near? + * + * For example, for semi-transparent materials. + */ + val isTranslucent: Boolean + + fun bind(context: MaterialBindContext) + + /** Undoes whatever [bind] set if it needs to be undone. */ + fun clear(context: MaterialBindContext) = Unit +} + +/** + * What a material is being bound for. + */ +class MaterialBindContext(val shader: ShaderInstance, val colorLocation: Int = -1) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/DebugLines.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/DebugLines.kt index 2edd3d78f..2e440b793 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/DebugLines.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/DebugLines.kt @@ -26,6 +26,11 @@ object DebugLines { val OVERLAY: RenderType = overlayLines("hollowengine:debug_overlay_lines", RenderStateShard.ITEM_ENTITY_TARGET) val PANEL: RenderType = overlayLines("hollowengine:debug_panel_lines", RenderStateShard.MAIN_TARGET) + val BOUND: RenderType = overlayLines( + "hollowengine:debug_bound_lines", + RenderStateShard.OutputStateShard("hollowengine:bound_target", {}, {}), + ) + private fun overlayLines(name: String, target: RenderStateShard.OutputStateShard) = RenderType.create( name, DefaultVertexFormat.POSITION_COLOR_NORMAL, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/RenderManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/RenderManager.kt index 05ed6c16d..b67b19d20 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/RenderManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/render/RenderManager.kt @@ -197,10 +197,10 @@ object RenderManager { packedLight: Int, ): Int { val (min, max) = attachment.calculateBounds() ?: return packedLight - val centre = MutableVec3f() - worldTransform.matrixF.transform((min + max) * 0.5f, 1f, centre) + val center = MutableVec3f() + worldTransform.matrixF.transform((min + max) * 0.5f, 1f, center) - val position = BlockPos.containing(centre.x.toDouble(), centre.y.toDouble(), centre.z.toDouble()) + val position = BlockPos.containing(center.x.toDouble(), center.y.toDouble(), center.z.toDouble()) if (position == entity.blockPosition()) return packedLight if (!level.getBlockState(position).isAir) { val above = position.above() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/scripting/ConsoleScript.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/scripting/ConsoleScript.kt index 6d4ec8804..2f5455c38 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/scripting/ConsoleScript.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/scripting/ConsoleScript.kt @@ -6,19 +6,18 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.job +import kotlinx.coroutines.launch import net.minecraft.client.Minecraft import net.minecraft.client.multiplayer.ClientLevel import net.minecraft.client.player.LocalPlayer import net.minecraft.client.server.IntegratedServer import org.apache.logging.log4j.LogManager -import org.apache.logging.log4j.Logger +import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher import ru.hollowhorizon.hollowengine.common.events.LogicalSide import ru.hollowhorizon.hollowengine.common.scripting.CONSOLE_SCRIPT_EXTENSION -import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment -import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptCompilationException -import java.util.concurrent.Executors -import kotlin.script.experimental.api.constructorArgs +import ru.hollowhorizon.hollowengine.common.scripting.console.SnippetRun +import ru.hollowhorizon.hollowengine.common.scripting.console.SnippetRunner /** * Base of snippets typed into the IDE console. A snippet runs on the render thread and works in the @@ -37,7 +36,7 @@ abstract class ConsoleScript(scope: CoroutineScope) : CoroutineScope by scope { /** Prints to the console, not to the game's standard output. */ fun println(value: Any?) { - ConsoleScripts.LOGGER.info(value.toString()) + ConsoleScripts.runner.print(value.toString()) } fun print(value: Any?) = println(value) @@ -48,11 +47,7 @@ object ConsoleScripts { /** Also the name the IDE analyzes the console input under, so both agree on the script type. */ const val SCRIPT_NAME = "console.$CONSOLE_SCRIPT_EXTENSION" - internal val LOGGER: Logger = LogManager.getLogger("Console") - - private val compiler = Executors.newSingleThreadExecutor { task -> - Thread(task, "HollowEngine-ConsoleCompiler").apply { isDaemon = true } - } + internal val runner = SnippetRunner(SCRIPT_NAME, LogManager.getLogger("Console"), "HollowEngine-ConsoleCompiler") @Volatile private var scope: CoroutineScope? = null @@ -62,26 +57,22 @@ object ConsoleScripts { * snippet's last expression evaluates to is printed, like in a REPL. */ fun run(code: String, onFinished: () -> Unit) { - val environment = ScriptingEnvironment.currentOrNull() - if (environment == null) { - LOGGER.warn("Kotlin snippets need the compiler addon, which is not installed") - onFinished() - return - } - LOGGER.info("> {}", code.trimEnd()) - compiler.execute { - val compiled = environment.compiler.compile(SCRIPT_NAME, code) - Minecraft.getInstance().execute { - compiled.onSuccess { script -> - script.evaluate { constructorArgs(currentScope()) } - .onSuccess { result -> if (result.hasValue) LOGGER.info("= {}", result.value) } - .onFailure { error -> LOGGER.error("The snippet failed", error) } - }.onFailure(::reportCompilationFailure) + val minecraft = Minecraft.getInstance() + minecraft.coroutineScope.launch { + try { + evaluate(code) + } finally { onFinished() } } } + /** Compiles and runs [code] like [run], and hands back what it printed and returned. */ + suspend fun evaluate(code: String): SnippetRun { + val minecraft = Minecraft.getInstance() + return runner.run(code, minecraft.dispatcher) { listOf(currentScope()) } + } + /** Whether coroutines a snippet launched are still alive. */ val hasRunningWork: Boolean get() = scope?.coroutineContext?.job?.children?.any(Job::isActive) == true @@ -95,17 +86,4 @@ object ConsoleScripts { private fun currentScope(): CoroutineScope = scope ?: CoroutineScope( SupervisorJob() + Minecraft.getInstance().dispatcher + LogicalSide.CLIENT + CoroutineName("Console snippets"), ).also { scope = it } - - private fun reportCompilationFailure(error: Throwable) { - val reports = (error as? ScriptCompilationException)?.reports - if (reports == null) { - LOGGER.error("The snippet did not compile", error) - return - } - reports.filter { it.severity.isError() }.forEach { report -> - val start = report.range.start - if (start.line < 0) LOGGER.error(report.message) - else LOGGER.error("{}:{}: {}", start.line + 1, start.column + 1, report.message) - } - } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderExpression.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderExpression.kt new file mode 100644 index 000000000..7efffe7ba --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderExpression.kt @@ -0,0 +1,355 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +/** What can be wrong with the text of an expression node; each has a text under `expression.`. */ +enum class ShaderExpressionProblem { + EMPTY, UNEXPECTED_CHARACTER, UNEXPECTED_TOKEN, EXPECTED_VALUE, UNCLOSED, UNKNOWN_FUNCTION, RESERVED_NAME, +} + +class ShaderExpressionError(val problem: ShaderExpressionProblem, val start: Int, val end: Int, val detail: String = "") + +enum class ShaderTokenKind { + NUMBER, + + /** A name that becomes a pin of the node. */ + VARIABLE, + + /** A name that stands for a number, such as `PI`. */ + CONSTANT, + + BOOLEAN, FUNCTION, + + /** What follows a dot: a swizzle such as `xy`. */ + MEMBER, OPERATOR, END, +} + +class ShaderToken(val kind: ShaderTokenKind, val text: String, val start: Int, val end: Int) + +/** + * The text of an expression node: one GLSL expression over names of its own choosing, such as + * `(sin(x) + 1) / 2`. Every free name becomes an input of the node, and the expression is its output. + */ +class ShaderExpression private constructor( + val source: String, + val tokens: List, + val error: ShaderExpressionError?, +) { + /** The names that become pins, in the order they first appear. */ + val variables: List = tokens.filter { it.kind == ShaderTokenKind.VARIABLE }.map { it.text }.distinct() + + /** The GLSL of the expression with each name read through [codeOf], or null when the text is not valid. */ + fun glsl(codeOf: (String) -> String): String? { + if (error != null) return null + return buildString { + tokens.forEach { token -> + when (token.kind) { + ShaderTokenKind.VARIABLE -> append('(').append(codeOf(token.text)).append(')') + ShaderTokenKind.CONSTANT -> append(Constants.getValue(token.text)) + ShaderTokenKind.NUMBER -> append(floatLiteral(token.text)) + ShaderTokenKind.OPERATOR -> when (token.text) { + ".", "(", ")" -> append(token.text) + "," -> append(", ") + else -> append(' ').append(token.text).append(' ') + } + + ShaderTokenKind.END -> Unit + else -> append(token.text) + } + } + }.trim() + } + + /** + * How many components the expression has, one to four, given how many each name has through + * [widthOf]; null when the text is not valid or that cannot be told. + */ + fun width(widthOf: (String) -> Int?): Int? { + if (error != null) return null + val parser = Parser(tokens, widthOf) + parser.run() + return parser.width.takeIf { it in 1..4 } + } + + companion object { + /** The functions an expression can call, with their parameters, for completion. */ + val Functions: Map = linkedMapOf( + "sin" to "(x)", "cos" to "(x)", "tan" to "(x)", "asin" to "(x)", "acos" to "(x)", "atan" to "(y, x)", + "sinh" to "(x)", "cosh" to "(x)", "tanh" to "(x)", "radians" to "(degrees)", "degrees" to "(radians)", + "pow" to "(x, y)", "exp" to "(x)", "log" to "(x)", "exp2" to "(x)", "log2" to "(x)", "sqrt" to "(x)", + "inversesqrt" to "(x)", "abs" to "(x)", "sign" to "(x)", "floor" to "(x)", "ceil" to "(x)", + "round" to "(x)", "trunc" to "(x)", "fract" to "(x)", "mod" to "(x, y)", "min" to "(x, y)", + "max" to "(x, y)", "clamp" to "(x, min, max)", "mix" to "(a, b, t)", "step" to "(edge, x)", + "smoothstep" to "(edge0, edge1, x)", "length" to "(v)", "distance" to "(a, b)", "dot" to "(a, b)", + "cross" to "(a, b)", "normalize" to "(v)", "reflect" to "(i, n)", "refract" to "(i, n, eta)", + "float" to "(x)", "vec2" to "(x, y)", "vec3" to "(x, y, z)", "vec4" to "(x, y, z, w)", + "dFdx" to "(x)", "dFdy" to "(x)", "fwidth" to "(x)", + ) + + /** Names that read as numbers, and the number each is written as. */ + val Constants: Map = linkedMapOf( + "PI" to "3.14159265", + "TAU" to "6.28318531", + "E" to "2.71828183", + ) + + /** The literals of GLSL that look like names; without them here they would become pins. */ + val Booleans = setOf("true", "false") + + private val Reserved = setOf( + "if", "else", "for", "while", "do", "return", "break", "continue", "discard", "struct", "const", + "uniform", "in", "out", "inout", "int", "uint", "bool", "mat2", "mat3", "mat4", "sampler2D", "void", + ) + + private val TwoCharOperators = setOf("<=", ">=", "==", "!=", "&&", "||") + private const val OneCharOperators = "+-*/<>!?:.()," + + fun parse(source: String): ShaderExpression { + val lexed = lex(source) + val tokens = lexed.first + val error = lexed.second ?: check(source, tokens) ?: Parser(tokens).run() + return ShaderExpression(source, tokens, error) + } + + private fun lex(source: String): Pair, ShaderExpressionError?> { + val tokens = ArrayList() + var at = 0 + while (at < source.length) { + val char = source[at] + val pair = if (at + 1 < source.length) source.substring(at, at + 2) else "" + when { + char.isWhitespace() -> at++ + char.isDigit() || char == '.' && source.getOrNull(at + 1)?.isDigit() == true -> { + val end = numberEnd(source, at) + tokens += ShaderToken(ShaderTokenKind.NUMBER, source.substring(at, end), at, end) + at = end + } + + char.isLetter() || char == '_' -> { + var end = at + while (end < source.length && (source[end].isLetterOrDigit() || source[end] == '_')) end++ + val name = source.substring(at, end) + tokens += ShaderToken(nameKind(source, name, tokens.lastOrNull(), end), name, at, end) + at = end + } + + pair in TwoCharOperators -> { + tokens += ShaderToken(ShaderTokenKind.OPERATOR, pair, at, at + 2) + at += 2 + } + + char in OneCharOperators -> { + tokens += ShaderToken(ShaderTokenKind.OPERATOR, char.toString(), at, at + 1) + at++ + } + + else -> return tokens to ShaderExpressionError( + ShaderExpressionProblem.UNEXPECTED_CHARACTER, + at, + at + 1, + char.toString() + ) + } + } + tokens += ShaderToken(ShaderTokenKind.END, "", source.length, source.length) + return tokens to null + } + + private fun numberEnd(source: String, start: Int): Int { + var end = start + while (end < source.length && source[end].isDigit()) end++ + if (end < source.length && source[end] == '.') { + end++ + while (end < source.length && source[end].isDigit()) end++ + } + if (end < source.length && (source[end] == 'e' || source[end] == 'E')) { + var exponent = end + 1 + if (exponent < source.length && (source[exponent] == '+' || source[exponent] == '-')) exponent++ + if (exponent < source.length && source[exponent].isDigit()) { + while (exponent < source.length && source[exponent].isDigit()) exponent++ + end = exponent + } + } + return end + } + + /** A name after a dot is a member, one before a parenthesis a call, a known one a constant, anything else a pin. */ + private fun nameKind(source: String, name: String, previous: ShaderToken?, end: Int): ShaderTokenKind = when { + previous?.kind == ShaderTokenKind.OPERATOR && previous.text == "." -> ShaderTokenKind.MEMBER + source.substring(end).trimStart().startsWith("(") -> ShaderTokenKind.FUNCTION + name in Constants -> ShaderTokenKind.CONSTANT + name in Booleans -> ShaderTokenKind.BOOLEAN + else -> ShaderTokenKind.VARIABLE + } + + private fun check(source: String, tokens: List): ShaderExpressionError? { + if (source.isBlank()) return ShaderExpressionError(ShaderExpressionProblem.EMPTY, 0, source.length) + tokens.forEach { token -> + when (token.kind) { + ShaderTokenKind.FUNCTION -> if (token.text !in Functions) { + return ShaderExpressionError( + ShaderExpressionProblem.UNKNOWN_FUNCTION, + token.start, + token.end, + token.text + ) + } + + ShaderTokenKind.VARIABLE -> if (isReserved(token.text)) { + return ShaderExpressionError( + ShaderExpressionProblem.RESERVED_NAME, + token.start, + token.end, + token.text + ) + } + + else -> Unit + } + } + return null + } + + private fun isReserved(name: String): Boolean = + name in Reserved || name in Functions || name.startsWith("gl_") || name.startsWith("sg_") || name.startsWith( + "p_" + ) || "__" in name + + /** GLSL takes `2` as an int; every number is written as a float so it mixes with the rest. */ + private fun floatLiteral(text: String): String = when { + '.' in text || 'e' in text || 'E' in text -> text + else -> "$text.0" + } + } +} + +private class Parser(private val tokens: List, private val widthOf: (String) -> Int? = { null }) { + private var at = 0 + + var width: Int = UNKNOWN + private set + + private class Failure(val error: ShaderExpressionError) : RuntimeException(null, null, false, false) + + fun run(): ShaderExpressionError? = try { + width = expression() + if (peek().kind != ShaderTokenKind.END) fail(ShaderExpressionProblem.UNEXPECTED_TOKEN) + null + } catch (failure: Failure) { + failure.error + } + + private fun peek(): ShaderToken = tokens[at] + private fun isOperator(text: String): Boolean = peek().kind == ShaderTokenKind.OPERATOR && peek().text == text + + private fun fail(problem: ShaderExpressionProblem): Nothing { + val token = peek() + throw Failure(ShaderExpressionError(problem, token.start, token.end.coerceAtLeast(token.start), token.text)) + } + + private fun expect(text: String) { + if (isOperator(text)) { + at++ + return + } + fail(if (peek().kind == ShaderTokenKind.END) ShaderExpressionProblem.UNCLOSED else ShaderExpressionProblem.UNEXPECTED_TOKEN) + } + + private fun expression(): Int { + val condition = level(0) + if (!isOperator("?")) return condition + at++ + val whenTrue = expression() + expect(":") + return widest(whenTrue, expression()) + } + + private fun level(index: Int): Int { + if (index == Levels.size) return unary() + var width = level(index + 1) + while (peek().kind == ShaderTokenKind.OPERATOR && peek().text in Levels[index]) { + val operator = peek().text + at++ + val right = level(index + 1) + width = if (operator in Arithmetic) widest(width, right) else 1 + } + return width + } + + private fun unary(): Int { + if (isOperator("-") || isOperator("+")) { + at++ + return unary() + } + if (isOperator("!")) { + at++ + unary() + return 1 + } + var width = primary() + while (isOperator(".")) { + at++ + val member = peek() + if (member.kind != ShaderTokenKind.MEMBER) fail(ShaderExpressionProblem.UNEXPECTED_TOKEN) + at++ + width = member.text.length.takeIf { it in 1..4 } ?: UNKNOWN + } + return width + } + + private fun primary(): Int { + val token = peek() + return when { + token.kind in Literals -> { + at++ + 1 + } + + token.kind == ShaderTokenKind.VARIABLE -> { + at++ + widthOf(token.text) ?: UNKNOWN + } + + token.kind == ShaderTokenKind.FUNCTION -> { + at++ + expect("(") + val arguments = ArrayList() + if (!isOperator(")")) { + arguments += expression() + while (isOperator(",")) { + at++ + arguments += expression() + } + } + expect(")") + resultWidth(token.text, arguments) + } + + isOperator("(") -> { + at++ + expression().also { expect(")") } + } + + else -> fail(ShaderExpressionProblem.EXPECTED_VALUE) + } + } + + private fun resultWidth(function: String, arguments: List): Int = when (function) { + "length", "distance", "dot", "float" -> 1 + "vec2" -> 2 + "vec3", "cross" -> 3 + "vec4" -> 4 + else -> arguments.fold(1, ::widest) + } + + private fun widest(a: Int, b: Int): Int = if (a == UNKNOWN || b == UNKNOWN) UNKNOWN else maxOf(a, b) + + companion object { + private val Literals = setOf(ShaderTokenKind.NUMBER, ShaderTokenKind.CONSTANT, ShaderTokenKind.BOOLEAN) + + const val UNKNOWN = -1 + + private val Levels = listOf( + setOf("||"), setOf("&&"), setOf("==", "!="), setOf("<", ">", "<=", ">="), setOf("+", "-"), setOf("*", "/"), + ) + private val Arithmetic = setOf("+", "-", "*", "/") + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraph.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraph.kt new file mode 100644 index 000000000..124235045 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraph.kt @@ -0,0 +1,108 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** What a graph shades, which decides its output node and what the engine hands it. */ +@Serializable +enum class ShaderTarget { + /** The surface of a particle, a mesh or a ribbon of an effect. */ + SURFACE, +} + +/** + * One node of a graph: its kind, where it sits on the canvas, the values its unconnected inputs take + * and the choices it offers, such as which property a property node reads. + */ +@Serializable +data class ShaderGraphNode( + val id: String, + val type: String, + val x: Float = 0f, + val y: Float = 0f, + val values: Map> = emptyMap(), + val options: Map = emptyMap(), + val preview: Boolean? = null, +) + +/** What the previews of the editor draw a graph on. */ +@Serializable +enum class ShaderPreviewMesh { + QUAD, SPHERE, CUBE, +} + +/** + * How the editor previews a graph. It is kept with the graph so it opens the way it was left, but + * nothing outside the editor reads it. + */ +@Serializable +data class ShaderGraphPreview( + val mesh: ShaderPreviewMesh = ShaderPreviewMesh.SPHERE, + val rotate: Boolean = true, + val texture: String = DEFAULT_TEXTURE, +) { + companion object { + const val DEFAULT_TEXTURE = "hollowengine:textures/particle/circle.png" + } +} + +/** A link from an output of one node to an input of another. */ +@Serializable +data class ShaderGraphLink( + val from: String, + val output: String, + val to: String, + val input: String, +) + +/** + * A value the graph leaves to whoever uses it: each material that names the graph sets its own. It + * reaches the shader as a uniform, or a sampler for a texture, under [name]. + */ +@Serializable +data class ShaderGraphProperty( + val name: String, + val type: ShaderType = ShaderType.FLOAT, + val default: List = emptyList(), + val texture: String = "", +) + +/** + * A shader as a graph: one file for both the vertex and the fragment stage. The compiler splits it + * by what each output of the output node needs. It is stored as a `.material`, since that is what an + * effect names: a surface with its properties. + */ +@Serializable +data class ShaderGraph( + val version: Int = CURRENT_VERSION, + val target: ShaderTarget = ShaderTarget.SURFACE, + val nodes: List = emptyList(), + val links: List = emptyList(), + val properties: List = emptyList(), + val preview: ShaderGraphPreview = ShaderGraphPreview(), +) { + fun node(id: String): ShaderGraphNode? = nodes.firstOrNull { it.id == id } + + fun linkInto(node: String, input: String): ShaderGraphLink? = links.lastOrNull { it.to == node && it.input == input } + + fun property(name: String): ShaderGraphProperty? = properties.firstOrNull { it.name == name } + + companion object { + const val CURRENT_VERSION = 1 + const val EXTENSION = ".material" + } +} + +/** How a graph is stored: JSON, with what equals the defaults left out. */ +object ShaderGraphFormat { + private val json = Json { + prettyPrint = true + encodeDefaults = false + ignoreUnknownKeys = true + } + + fun read(text: String): ShaderGraph = + if (text.isBlank()) ShaderGraph() else json.decodeFromString(ShaderGraph.serializer(), text) + + fun write(graph: ShaderGraph): String = json.encodeToString(ShaderGraph.serializer(), graph) +} 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 new file mode 100644 index 000000000..aed206caa --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompiler.kt @@ -0,0 +1,468 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +/** What can be wrong with a graph, for the editor to point at. */ +enum class ShaderProblem { + UNKNOWN_NODE, + UNKNOWN_PIN, + NO_OUTPUT_NODE, + SECOND_OUTPUT_NODE, + CYCLE, + TYPE_MISMATCH, + UNRESOLVED_TYPE, + FRAGMENT_ONLY_IN_VERTEX, + UNAVAILABLE_INPUT, + INVALID_EXPRESSION, + GLSL_ERROR, +} + +/** A problem, the node it is on, what it is about, and for some problems which of their kinds it is. */ +data class ShaderDiagnostic( + val problem: ShaderProblem, + val node: String? = null, + val detail: String = "", + val reason: String? = null, +) + +/** The GLSL of one stage: statements in order, each declaring the variables of one node's outputs. */ +class ShaderStageCode(val statements: List) { + val text: String get() = statements.joinToString("\n") +} + +/** + * A graph compiled for one place it runs: the code of each stage, what each input of the output node + * is, and everything the shader around it has to declare. + */ +class ShaderGraphCode( + /** The shared functions each stage calls; a fragment-only one, such as a derivative, never goes in the vertex stage. */ + val vertexLibraries: List, + val fragmentLibraries: List, + val properties: List, + val vertex: ShaderStageCode, + val fragment: ShaderStageCode, + val outputs: Map, + val linkedOutputs: Set, + val inputs: Set, + val diagnostics: List, + val types: ShaderGraphTypes, +) + +/** A value typed on a node that the preview program reads from `PreviewValues[index]`. */ +class ShaderValueSlot(val node: String, val pin: String, val width: Int) + +/** + * The whole graph as one fragment program, for the previews: every node is computed, and the uniform + * `PreviewNode` picks whose value is drawn, as [previewIndex] numbers them. + */ +class ShaderPreviewCode( + val libraries: List, + val vertexLibraries: List, + val vertex: ShaderStageCode, + val vertexSelection: String, + val displaced: Set, + val properties: List, + val statements: ShaderStageCode, + val statementNodes: List, + val previewIndex: Map, + val spatial: Set, + val values: List, + val selection: String, + val diagnostics: List, +) + +/** The type every pin of a graph resolved to, for the editor to color links and check new ones. */ +class ShaderGraphTypes(private val outputs: Map, ShaderType>, private val inputs: Map, ShaderType>) { + fun output(node: String, pin: String): ShaderType? = outputs[node to pin] + fun input(node: String, pin: String): ShaderType? = inputs[node to pin] +} + +/** + * Turns a graph into GLSL. + * + * Nodes are pure: each output is one expression over the inputs, written to a variable of its own. + * A node runs in the vertex stage when an input of the output node listed as a vertex input depends + * on it, and in the fragment stage when any other does; a node both depend on is computed in both. + */ +object ShaderGraphCompiler { + /** Compiles [graph] for a place that offers [available] of the engine inputs. */ + fun compile(graph: ShaderGraph, available: Set = ShaderInput.entries.toSet()): ShaderGraphCode { + val resolved = ResolvedGraph(graph, values = null) + val diagnostics = resolved.diagnostics + val master = resolved.master + if (master == null) { + return ShaderGraphCode( + emptyList(), emptyList(), graph.properties, ShaderStageCode(emptyList()), ShaderStageCode(emptyList()), + emptyMap(), emptySet(), emptySet(), diagnostics, resolved.types(), + ) + } + + val masterType = resolved.typeOf(master) + val masterInputs = masterType.inputs(master) + val vertexOutputs = masterType.master?.vertexInputs.orEmpty() + val linked = masterInputs.filter { graph.linkInto(master.id, it.name) != null }.map { it.name }.toSet() + val vertexRoots = linked.filter { it in vertexOutputs }.mapNotNull { graph.linkInto(master.id, it)?.from } + val fragmentRoots = linked.filter { it !in vertexOutputs }.mapNotNull { graph.linkInto(master.id, it)?.from } + + val vertex = resolved.emit(resolved.reachableFrom(vertexRoots), vertex = true) + val fragment = resolved.emit(resolved.reachableFrom(fragmentRoots), vertex = false) + val used = vertex.inputs + fragment.inputs + (used - available).forEach { diagnostics += ShaderDiagnostic(ShaderProblem.UNAVAILABLE_INPUT, detail = it.name) } + + val outputs = masterInputs.associate { pin -> + pin.name to resolved.inputCode(master, pin, vertex = pin.name in vertexOutputs, sink = ArrayList()) + } + return ShaderGraphCode( + vertexLibraries = ShaderLibrary.ordered(vertex.libraries), + fragmentLibraries = ShaderLibrary.ordered(fragment.libraries), + properties = graph.properties, + vertex = ShaderStageCode(vertex.statements), + fragment = ShaderStageCode(fragment.statements), + outputs = outputs, + linkedOutputs = linked, + inputs = used, + diagnostics = diagnostics.distinct(), + types = resolved.types(), + ) + } + + /** Compiles every node of [graph] into one program the previews share. */ + fun compilePreview(graph: ShaderGraph): ShaderPreviewCode { + val values = PreviewValues() + val resolved = ResolvedGraph(graph, values) + val nodes = resolved.order.filter { resolved.typeOf(it).master == null } + val emitted = resolved.emit(nodes, vertex = false) + + val previewIndex = LinkedHashMap() + val branches = ArrayList() + nodes.filter { resolved.typeOf(it).showsPreview(it) }.forEach { node -> + val output = resolved.typeOf(node).outputs.first() + val value = when (resolved.outputType(node, output)) { + ShaderType.TEXTURE -> "texture(${resolved.variable(node, output)}, ${ShaderInput.UV.glsl})" + else -> resolved.variable(node, output) + } + previewIndex[node.id] = previewIndex.size + val shown = when (resolved.typeOf(node).previewStyle) { + ShaderPreviewStyle.DISPLACEMENT -> "sg_show_shape()" + ShaderPreviewStyle.SIGNED -> coerce(value, resolved.outputType(node, output), ShaderType.VEC3) + ?.let { "sg_show_signed($it)" } ?: "sg_show($value)" + + ShaderPreviewStyle.VALUE -> "sg_show($value)" + } + branches += "if (PreviewNode == ${previewIndex.getValue(node.id)}) fragColor = $shown;" + } + resolved.master?.takeIf { resolved.typeOf(it).showsPreview(it) }?.let { master -> + val code = resolved.typeOf(master).inputs(master).associate { pin -> + pin.name to resolved.inputCode(master, pin, vertex = false, sink = ArrayList()) + } + previewIndex[master.id] = previewIndex.size + branches += "if (PreviewNode == ${previewIndex.getValue(master.id)}) fragColor = sg_surface(" + + "${code[SurfaceOutputs.COLOR] ?: "vec3(1.0)"}, ${code[SurfaceOutputs.ALPHA] ?: "1.0"}, " + + "${code[SurfaceOutputs.EMISSION] ?: "vec3(0.0)"}, ${code[SurfaceOutputs.ALPHA_CLIP] ?: "0.0"});" + } + + val moved = previewOffsets(graph, resolved, previewIndex) + val movedNodes = moved.flatMap { it.nodes }.map { it.id }.toSet() + val vertex = resolved.emit(resolved.order.filter { it.id in movedNodes }, vertex = true) + return ShaderPreviewCode( + libraries = ShaderLibrary.ordered(emitted.libraries), + vertexLibraries = ShaderLibrary.ordered(vertex.libraries), + vertex = ShaderStageCode(vertex.statements), + vertexSelection = moved.joinToString("\n") { "if (PreviewNode == ${it.index}) sg_out_vertex_offset = ${it.offset};" }, + displaced = moved.map { it.node }.toSet(), + properties = graph.properties, + statements = ShaderStageCode(emitted.statements), + statementNodes = emitted.owners, + previewIndex = previewIndex, + spatial = resolved.spatial(), + values = values.slots, + selection = "fragColor = vec4(0.0);\n" + branches.joinToString("\n"), + diagnostics = resolved.diagnostics.distinct(), + ) + } +} + +private class PreviewOffset(val node: String, val index: Int, val offset: String, val nodes: List) + +private fun previewOffsets(graph: ShaderGraph, resolved: ResolvedGraph, previewIndex: Map): List { + val offsets = ArrayList() + resolved.master?.let { master -> + val index = previewIndex[master.id] ?: return@let + val link = graph.linkInto(master.id, SurfaceOutputs.VERTEX_OFFSET) ?: return@let + val pin = resolved.typeOf(master).input(master, SurfaceOutputs.VERTEX_OFFSET) ?: return@let + val nodes = resolved.reachableFrom(listOf(link.from)) + if (nodes.none(resolved::needsFragment)) { + offsets += PreviewOffset(master.id, index, resolved.inputCode(master, pin, vertex = true, sink = ArrayList()), nodes) + } + } + resolved.order.filter { resolved.typeOf(it).previewStyle == ShaderPreviewStyle.DISPLACEMENT }.forEach { node -> + val index = previewIndex[node.id] ?: return@forEach + val output = resolved.typeOf(node).outputs.first() + val offset = coerce(resolved.variable(node, output), resolved.outputType(node, output), ShaderType.VEC3) ?: return@forEach + val nodes = resolved.reachableFrom(listOf(node.id)) + if (nodes.none(resolved::needsFragment)) offsets += PreviewOffset(node.id, index, offset, nodes) + } + return offsets +} + +/** The values of a preview program, each given a slot of the uniform array as the code reads it. */ +private class PreviewValues { + val slots = ArrayList() + private val taken = HashMap, Int>() + + /** What reads the value of [pin] of [node], or null once the array is full and the value has to be a literal. */ + fun read(node: String, pin: String, type: ShaderType): String? { + val index = taken.getOrPut(node to pin) { + if (slots.size >= LIMIT) return null + slots += ShaderValueSlot(node, pin, type.width) + slots.size - 1 + } + return when (type.width) { + 1 -> "PreviewValues[$index].x" + 2 -> "PreviewValues[$index].xy" + 3 -> "PreviewValues[$index].xyz" + else -> "PreviewValues[$index]" + } + } + + private companion object { + /** Well under the 256 vectors of uniforms a fragment stage is sure to have. */ + const val LIMIT = 192 + } +} + +/** The code of some nodes in one stage, and what it needs from around it. */ +private class EmittedStage( + val statements: List, + val owners: List, + val libraries: Set, + val inputs: Set, +) + +/** + * A graph with every node's kind looked up, the links checked, the nodes in dependency order and the + * type of every pin worked out. With [values], typed values are read from the preview uniforms. + */ +private class ResolvedGraph(private val graph: ShaderGraph, private val values: PreviewValues?) { + val diagnostics = ArrayList() + private val kinds = LinkedHashMap() + private val indices = HashMap() + private val outputTypes = HashMap, ShaderType>() + private val inputTypes = HashMap, ShaderType>() + private val dynamics = HashMap() + + val order: List + private val ordered: Set + val master: ShaderGraphNode? + + init { + graph.nodes.forEachIndexed { index, node -> + val kind = ShaderNodeTypes.of(node.type) + if (kind == null) { + diagnostics += ShaderDiagnostic(ShaderProblem.UNKNOWN_NODE, node.id, node.type) + } else { + kinds[node.id] = kind + indices[node.id] = index + kind.problem(node)?.let { diagnostics += it } + } + } + graph.links.forEach { link -> + 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) { + diagnostics += ShaderDiagnostic(ShaderProblem.UNKNOWN_PIN, link.from, link.output) + } + if (to != null && to.second.input(to.first, link.input) == null) { + diagnostics += ShaderDiagnostic(ShaderProblem.UNKNOWN_PIN, link.to, link.input) + } + } + + val masters = graph.nodes.filter { kinds[it.id]?.master?.target == graph.target } + master = masters.firstOrNull() + if (masters.isEmpty()) diagnostics += ShaderDiagnostic(ShaderProblem.NO_OUTPUT_NODE) + masters.drop(1).forEach { diagnostics += ShaderDiagnostic(ShaderProblem.SECOND_OUTPUT_NODE, it.id) } + + order = sortByDependencies() + ordered = order.map { it.id }.toSet() + order.forEach(::resolveTypes) + } + + fun typeOf(node: ShaderGraphNode): ShaderNodeType = kinds.getValue(node.id) + + fun outputType(node: ShaderGraphNode, output: ShaderOutputSpec): ShaderType = + outputTypes[node.id to output.name] ?: ShaderType.FLOAT + + /** + * What the rest of the graph reads for an output: its variable, or for a texture the sampler it + * names, since GLSL cannot hold a sampler in a local. A texture output reads no inputs. + */ + fun variable(node: ShaderGraphNode, output: ShaderOutputSpec): String { + if (outputType(node, output) == ShaderType.TEXTURE) { + val context = ShaderEmitContext(graph, node, ShaderType.FLOAT, ShaderType.TEXTURE, false, emptyMap(), emptyMap(), typeOf(node)) + return output.expression(context) + } + return "sg_${indices.getValue(node.id)}_${output.name.filter { it.isLetterOrDigit() }}" + } + + fun types() = ShaderGraphTypes(outputTypes.toMap(), inputTypes.toMap()) + + /** The link into [pin] of [node], when the node has that pin and it comes from an output of a usable node. */ + private fun source(node: ShaderGraphNode, pin: String): Pair? { + val link = graph.linkInto(node.id, pin) ?: return null + if (typeOf(node).input(node, pin) == null) return null + val from = graph.node(link.from) ?: return null + val output = kinds[from.id]?.output(link.output) ?: return null + if (from.id !in ordered) return null + return from to output + } + + private fun sortByDependencies(): List { + val done = LinkedHashSet() + val path = ArrayList() + val cyclic = HashSet() + fun visit(node: ShaderGraphNode) { + if (node.id in done) return + val onPath = path.indexOf(node.id) + if (onPath >= 0) { + cyclic += path.subList(onPath, path.size) + return + } + path += node.id + graph.links.filter { it.to == node.id }.forEach { link -> + graph.node(link.from)?.takeIf { it.id in kinds }?.let(::visit) + } + path.removeAt(path.lastIndex) + done += node.id + } + graph.nodes.filter { it.id in kinds }.forEach(::visit) + cyclic.forEach { diagnostics += ShaderDiagnostic(ShaderProblem.CYCLE, it) } + return done.filter { it !in cyclic }.mapNotNull(graph::node) + } + + private fun resolveTypes(node: ShaderGraphNode) { + val kind = typeOf(node) + val pins = kind.codeInputs(node) + val incoming = pins.associateWith { pin -> + source(node, pin.name)?.let { (from, output) -> outputType(from, output) } + ?: pin.fallback?.type + ?: pin.type.fixed + ?: ShaderType.ofWidth(values(node, pin).size.coerceIn(1, 4)) + } + val widest = pins.filter { it.type == ShaderPinType.DYNAMIC } + .mapNotNull { pin -> incoming.getValue(pin).takeIf { it.isVector }?.width } + .maxOrNull() ?: 1 + val dynamic = ShaderType.ofWidth(widest) + dynamics[node.id] = dynamic + + pins.forEach { pin -> + inputTypes[node.id to pin.name] = pin.type.fixed ?: if (pin.type == ShaderPinType.ANY) incoming.getValue(pin) else dynamic + } + kind.outputs.forEach { output -> + val declared = if (output.typeOf != null) output.typeOf.invoke(ShaderTypeContext(graph, node, typesOf(node))) else output.type + val type = when { + declared == null -> ShaderType.FLOAT.also { + diagnostics += ShaderDiagnostic(ShaderProblem.UNRESOLVED_TYPE, node.id, output.name) + } + + else -> declared.fixed ?: dynamic + } + outputTypes[node.id to output.name] = type + } + } + + private fun typesOf(node: ShaderGraphNode): Map = + typeOf(node).codeInputs(node).associate { it.name to (inputTypes[node.id to it.name] ?: ShaderType.FLOAT) } + + private fun values(node: ShaderGraphNode, pin: ShaderPinSpec): List = + node.values[pin.name]?.takeIf { it.isNotEmpty() } ?: pin.default.ifEmpty { listOf(0f) } + + /** Whether [node] can only run per fragment: it, or an engine input it falls back on, reads the scene or a derivative. */ + fun needsFragment(node: ShaderGraphNode): Boolean { + val kind = typeOf(node) + return kind.fragmentOnly || kind.reads.any { it.fragmentOnly } || + kind.inputs(node).any { pin -> pin.fallback?.fragmentOnly == true && source(node, pin.name) == null } + } + + fun reachableFrom(roots: List): List { + val reached = HashSet() + val stack = ArrayDeque(roots) + while (stack.isNotEmpty()) { + val id = stack.removeLast() + if (!reached.add(id)) continue + graph.links.filter { it.to == id }.forEach { stack += it.from } + } + return order.filter { it.id in reached && typeOf(it).master == null } + } + + /** + * The nodes that read the shape of what they are drawn on, the normal, the view or the position, + * themselves or through what they are linked to; the output node always does. + */ + fun spatial(): Set { + val shaped = setOf(ShaderInput.NORMAL, ShaderInput.VIEW_DIRECTION, ShaderInput.POSITION, ShaderInput.OBJECT_POSITION) + val result = HashSet() + order.forEach { node -> + val kind = typeOf(node) + val own = kind.master != null || kind.reads.any { it in shaped } || kind.inputs(node).any { pin -> + pin.fallback in shaped && source(node, pin.name) == null + } + val inherited = kind.inputs(node).any { pin -> source(node, pin.name)?.first?.id in result } + if (own || inherited) result += node.id + } + return result + } + + /** + * The GLSL an input of [node] reads: the output linked to it, read as the pin's type, the engine + * input it falls back on, or the value typed on the node. What it uses from the engine goes to [sink]. + */ + fun inputCode(node: ShaderGraphNode, pin: ShaderPinSpec, vertex: Boolean, sink: MutableCollection): String { + val type = inputTypes[node.id to pin.name] ?: pin.type.fixed ?: ShaderType.FLOAT + source(node, pin.name)?.let { (from, output) -> + val fromType = outputType(from, output) + coerce(variable(from, output), fromType, type)?.let { return it } + diagnostics += ShaderDiagnostic(ShaderProblem.TYPE_MISMATCH, node.id, "${pin.name}: $fromType -> $type") + } + pin.fallback?.let { input -> + coerce(input.glsl, input.type, type)?.let { code -> + sink += input + if (vertex && input.fragmentOnly) { + diagnostics += ShaderDiagnostic(ShaderProblem.FRAGMENT_ONLY_IN_VERTEX, node.id, input.name) + } + return code + } + } + if (type == ShaderType.TEXTURE) { + sink += ShaderInput.MAIN_TEXTURE + return ShaderInput.MAIN_TEXTURE.glsl + } + return values?.read(node.id, pin.name, type) ?: shaderLiteral(type, values(node, pin)) + } + + /** The statements of [nodes] for one stage, in their order. */ + fun emit(nodes: List, vertex: Boolean): EmittedStage { + val statements = ArrayList() + val owners = ArrayList() + val libraries = LinkedHashSet() + val inputs = LinkedHashSet() + nodes.forEach { node -> + val kind = typeOf(node) + if (vertex && (kind.fragmentOnly || kind.reads.any { it.fragmentOnly })) { + diagnostics += ShaderDiagnostic(ShaderProblem.FRAGMENT_ONLY_IN_VERTEX, node.id, kind.id) + } + libraries += kind.libraries + inputs += kind.reads + val code = kind.codeInputs(node).associate { pin -> pin.name to inputCode(node, pin, vertex, inputs) } + val written = LinkedHashMap() + kind.outputs.forEach { output -> + val type = outputType(node, output) + val name = variable(node, output) + if (type != ShaderType.TEXTURE) { + val context = ShaderEmitContext(graph, node, dynamics.getValue(node.id), type, vertex, code, written, kind) + statements += "${type.glsl} $name = ${output.expression(context)};" + owners += node.id + } + written[output.name] = name + } + } + return EmittedStage(statements, owners, libraries, inputs) + } +} 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 new file mode 100644 index 000000000..4f7294c63 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEdits.kt @@ -0,0 +1,125 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +/** An id no node of the graph has yet, readable, since it shows up in the file. */ +fun ShaderGraph.freeNodeId(type: String): String { + val base = type.substringAfterLast('/').ifBlank { "node" } + var index = 1 + while (nodes.any { it.id == "${base}_$index" }) index++ + return "${base}_$index" +} + +fun ShaderGraph.withNode(node: ShaderGraphNode): ShaderGraph = copy(nodes = nodes + node) + +/** Without the node and every link into or out of it. */ +fun ShaderGraph.withoutNode(id: String): ShaderGraph = copy( + nodes = nodes.filterNot { it.id == id }, + links = links.filterNot { it.from == id || it.to == id }, +) + +/** Without the nodes of [ids] and every link into or out of them. */ +fun ShaderGraph.withoutNodes(ids: Set): ShaderGraph = copy( + nodes = nodes.filterNot { it.id in ids }, + links = links.filterNot { it.from in ids || it.to in ids }, +) + +fun ShaderGraph.withNodeAt(id: String, x: Float, y: Float): ShaderGraph = + mapNode(id) { it.copy(x = x, y = y) } + +/** With each node of [positions] moved to where it says. */ +fun ShaderGraph.withNodesAt(positions: Map>): ShaderGraph = copy( + nodes = nodes.map { node -> positions[node.id]?.let { (x, y) -> node.copy(x = x, y = y) } ?: node }, +) + +/** + * Copies of the nodes of [ids] next to them, with their values and options and the links between + * them, but none of the links that reach outside. Returns the ids of the copies too. + */ +fun ShaderGraph.withDuplicates(ids: Set, offset: Float = 30f): Pair> { + var graph = this + val renamed = LinkedHashMap() + nodes.filter { it.id in ids }.forEach { node -> + val copy = node.copy(id = graph.freeNodeId(node.type), x = node.x + offset, y = node.y + offset) + renamed[node.id] = copy.id + graph = graph.withNode(copy) + } + val copiedLinks = links.filter { it.from in renamed && it.to in renamed } + .map { it.copy(from = renamed.getValue(it.from), to = renamed.getValue(it.to)) } + return graph.copy(links = graph.links + copiedLinks) to renamed.values.toList() +} + +/** + * With a link from [output] of [from] into [input] of [to]. An input takes one link, so whatever was + * linked into it before is dropped; a link that would close a loop is not made. + */ +fun ShaderGraph.withLink(from: String, output: String, to: String, input: String): ShaderGraph { + if (from == to || dependsOn(from, to)) return this + return copy(links = links.filterNot { it.to == to && it.input == input } + ShaderGraphLink(from, output, to, input)) +} + +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 }) + +/** With [values] for the unconnected input [input] of the node. */ +fun ShaderGraph.withValue(node: String, input: String, values: List): ShaderGraph = + mapNode(node) { it.copy(values = it.values + (input to values)) } + +/** + * With [option] of the node set to [value]. A node whose pins follow its options, such as a shape or + * an expression, may lose a pin that way, and the links into a pin it lost go with it. + */ +fun ShaderGraph.withOption(node: String, option: String, value: String): ShaderGraph { + val changed = mapNode(node) { it.copy(options = it.options + (option to value)) } + val updated = changed.node(node) ?: return changed + val kind = ShaderNodeTypes.of(updated.type) ?: return changed + val pins = kind.inputs(updated).map { it.name }.toSet() + return changed.copy(links = changed.links.filterNot { it.to == node && it.input !in pins }) +} + +fun ShaderGraph.withPreview(node: String, shown: Boolean): ShaderGraph = mapNode(node) { it.copy(preview = shown) } + +fun ShaderGraph.withPreviewSettings(settings: ShaderGraphPreview): ShaderGraph = copy(preview = settings) + +/** An id no property has yet: `Property`, `Property2` and so on. */ +fun ShaderGraph.freePropertyName(base: String = "Property"): String { + if (properties.none { it.name == base }) return base + var index = 2 + while (properties.any { it.name == "$base$index" }) index++ + return "$base$index" +} + +fun ShaderGraph.withProperty(property: ShaderGraphProperty): ShaderGraph = + copy(properties = properties.filterNot { it.name == property.name } + property) + +/** With the property named [name] replaced by [property], and its nodes following a rename. */ +fun ShaderGraph.withPropertyChanged(name: String, property: ShaderGraphProperty): ShaderGraph { + if (property.name != name && properties.any { it.name == property.name }) return this + val renamed = nodes.map { node -> + if (node.type == ShaderNodeLibrary.PROPERTY && node.options["property"] == name) { + node.copy(options = node.options + ("property" to property.name)) + } else { + node + } + } + return copy(properties = properties.map { if (it.name == name) property else it }, nodes = renamed) +} + +fun ShaderGraph.withoutProperty(name: String): ShaderGraph = copy(properties = properties.filterNot { it.name == name }) + +/** Whether [node] reads, however far back, from [other]. */ +fun ShaderGraph.dependsOn(node: String, other: String): Boolean { + val seen = HashSet() + val stack = ArrayDeque(listOf(node)) + while (stack.isNotEmpty()) { + val current = stack.removeLast() + if (current == other) return true + if (!seen.add(current)) continue + links.filter { it.to == current }.forEach { stack += it.from } + } + return false +} + +private fun ShaderGraph.mapNode(id: String, change: (ShaderGraphNode) -> ShaderGraphNode) = + copy(nodes = nodes.map { if (it.id == id) change(it) else it }) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphPrograms.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphPrograms.kt new file mode 100644 index 000000000..1dfb9d538 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphPrograms.kt @@ -0,0 +1,89 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import com.mojang.blaze3d.vertex.VertexFormat +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.packs.resources.Resource +import net.minecraft.server.packs.resources.ResourceProvider +import ru.hollowhorizon.hollowengine.HollowEngine +import java.io.ByteArrayInputStream +import java.util.Optional + +/** A uniform a program json declares: its GLSL type as the json names it, and its starting values. */ +class ShaderGraphUniform(val name: String, val type: String, val values: List) + +/** + * Shader programs made out of generated source rather than files. The program and its sources live + * under `minecraft:shaders/core/hollowengine_graph/`, served from memory; everything they import or + * name besides that, such as a shared vertex shader, comes from the resource packs as usual. + */ +object ShaderGraphPrograms { + private const val FOLDER = "hollowengine_graph" + + /** + * A program named [name], out of [json] and its generated stages, keyed by extension (`vsh`, `fsh`); + * a failure carries what the driver said. + */ + fun create(name: String, json: String, stages: Map, format: VertexFormat): Result { + val resources = Minecraft.getInstance().resourceManager + val pack = Minecraft.getInstance().vanillaPackResources + val files = buildMap { + put("shaders/core/$FOLDER/$name.json", json) + stages.forEach { (extension, source) -> put("shaders/core/$FOLDER/$name.$extension", source) } + } + val provider = ResourceProvider { wanted -> + val text = files[wanted.path].takeIf { wanted.namespace == ResourceLocation.DEFAULT_NAMESPACE } + if (text != null) { + Optional.of(Resource(pack) { ByteArrayInputStream(text.toByteArray()) }) + } else { + resources.getResource(wanted) + } + } + return runCatching { ShaderInstance(provider, "$FOLDER/$name", format) }.onFailure { e -> + HollowEngine.LOGGER.warn("Could not build the shader graph program {}: {}", name, e.message) + } + } + + /** What a stage of [name] is called inside a program json. */ + fun stage(name: String): String = "$FOLDER/$name" + + /** A program json: the stages it names, its attributes, samplers and uniforms. */ + fun json( + vertex: String, + fragment: String, + attributes: List, + samplers: List, + uniforms: List, + ): String = buildJsonObject { + put("vertex", vertex) + put("fragment", fragment) + put("attributes", JsonArray(attributes.map(::JsonPrimitive))) + put("samplers", buildJsonArray { samplers.forEach { add(buildJsonObject { put("name", it) }) } }) + put("uniforms", buildJsonArray { + uniforms.forEach { uniform -> + add(buildJsonObject { + put("name", uniform.name) + put("type", uniform.type) + put("count", uniform.values.size) + put("values", JsonArray(uniform.values.map(::JsonPrimitive))) + }) + } + }) + }.toString() + + /** The uniform or sampler each property of a graph becomes. */ + fun propertyUniforms(properties: List): List = + properties.filter { it.type.isVector }.map { property -> + val defaults = (0 until property.type.width).map { property.default.getOrElse(it) { property.default.lastOrNull() ?: 0f } } + ShaderGraphUniform(propertyUniform(property.name), "float", defaults) + } + + fun propertySamplers(properties: List): List = + properties.filter { it.type == ShaderType.TEXTURE }.map { propertyUniform(it.name) } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphTemplates.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphTemplates.kt new file mode 100644 index 000000000..8b70b22c8 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphTemplates.kt @@ -0,0 +1,141 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +/** The two stages of a compiled graph, ready for a shader program. */ +class ShaderGraphSources(val vertex: String, val fragment: String) + +/** + * A shader a graph is compiled into: GLSL of the engine with marked places where the graph's code + * goes, and the engine inputs it defines for the graph. + */ +class ShaderGraphTemplate( + val name: String, + private val vertexPath: String?, + private val fragmentPath: String, + val available: Set, +) { + val vertexSource: String? by lazy { vertexPath?.let(::readTemplate) } + val fragmentSource: String by lazy { readTemplate(fragmentPath) } +} + +/** + * The templates the engine has, and how a compiled graph is put into one. + * + * Markers in a template: `//#uniforms` for the properties, `//#functions` for what the nodes share, + * `//#vertex` and `//#fragment` for each stage's code with the output node's inputs written to + * `sg_out_*`, and `//#preview` for the preview program. + */ +object ShaderGraphTemplates { + private const val ROOT = "/assets/hollowengine/shaders/graph/" + + /** Particle quads, drawn with the instance attributes of the engine particle program. */ + val PARTICLE = ShaderGraphTemplate( + name = "particle", + vertexPath = ROOT + "particle.vsh", + fragmentPath = ROOT + "particle.fsh", + available = ShaderInput.entries.toSet(), + ) + + /** The built-in meshes, drawn with the instance attributes of the engine mesh program. */ + val MESH = ShaderGraphTemplate( + name = "mesh", + vertexPath = ROOT + "mesh.vsh", + fragmentPath = ROOT + "surface.fsh", + available = ShaderInput.entries.toSet(), + ) + + /** Trails and beams, built on the CPU in the vanilla particle format. */ + val RIBBON = ShaderGraphTemplate( + name = "ribbon", + vertexPath = ROOT + "ribbon.vsh", + fragmentPath = ROOT + "surface.fsh", + available = ShaderInput.entries.toSet(), + ) + + /** The previews of the editor: a quad over a small target per node. */ + val PREVIEW = ShaderGraphTemplate( + name = "preview", + vertexPath = ROOT + "preview.vsh", + fragmentPath = ROOT + "preview.fsh", + available = ShaderInput.entries.toSet(), + ) + + /** [code] of a surface graph put into [template]. */ + fun surface(code: ShaderGraphCode, template: ShaderGraphTemplate): ShaderGraphSources { + val vertexSource = requireNotNull(template.vertexSource) { "${template.name} has no vertex stage" } + val vertex = buildString { + appendLine(code.vertex.text) + if (SurfaceOutputs.VERTEX_OFFSET in code.linkedOutputs) { + append("sg_out_vertex_offset = ${code.outputs.getValue(SurfaceOutputs.VERTEX_OFFSET)};") + } + } + val fragment = buildString { + appendLine(code.fragment.text) + appendLine("sg_out_color = ${code.outputs.getValue(SurfaceOutputs.COLOR)};") + appendLine("sg_out_alpha = ${code.outputs.getValue(SurfaceOutputs.ALPHA)};") + appendLine("sg_out_alpha_clip = ${code.outputs.getValue(SurfaceOutputs.ALPHA_CLIP)};") + if (SurfaceOutputs.EMISSION in code.linkedOutputs) { + appendLine("sg_out_emission = ${code.outputs.getValue(SurfaceOutputs.EMISSION)};") + append("sg_has_emission = true;") + } + } + return ShaderGraphSources( + vertex = fill(vertexSource, code.properties, code.vertexLibraries, "//#vertex" to vertex), + fragment = fill(template.fragmentSource, code.properties, code.fragmentLibraries, "//#fragment" to fragment), + ) + } + + /** The fragment stage of the preview program of a graph. */ + fun preview(code: ShaderPreviewCode): String = fill( + PREVIEW.fragmentSource, + code.properties, + code.libraries, + "//#preview" to code.statements.text + "\n" + code.selection, + extraUniforms = previewValues(code), + ) + + /** The vertex stage of the preview program of a graph: the offsets of the previews that move the mesh. */ + fun previewVertex(code: ShaderPreviewCode): String { + val body = if (code.displaced.isEmpty()) "" else code.vertex.text + "\n" + code.vertexSelection + return fill( + requireNotNull(PREVIEW.vertexSource), + code.properties, + code.vertexLibraries, + "//#vertex" to body, + extraUniforms = previewValues(code), + ) + } + + /** The array the values typed on nodes are uploaded into; both stages declare it alike, so they share it. */ + private fun previewValues(code: ShaderPreviewCode): String = + if (code.values.isEmpty()) "" else "uniform vec4 PreviewValues[${code.values.size}];" + + /** + * The line of [source], a preview program [preview] made out of [code], each statement of the graph + * starts on, counted from 1 as GLSL compilers report them. + */ + fun previewStatementLines(source: String, code: ShaderPreviewCode): Int? { + val first = code.statements.statements.firstOrNull() ?: return null + val at = source.indexOf(" $first").takeIf { it >= 0 } ?: return null + return source.substring(0, at).count { it == '\n' } + 1 + } + + private fun fill( + source: String, + properties: List, + libraries: List, + body: Pair, + extraUniforms: String = "", + ): String = source + .replace( + "//#uniforms", + (properties.map { "uniform ${it.type.glsl} ${propertyUniform(it.name)};" } + extraUniforms) + .filter { it.isNotEmpty() } + .joinToString("\n"), + ) + .replace("//#functions", libraries.joinToString("\n") { it.code.trimIndent() }) + .replace(body.first, body.second.prependIndent(" ")) +} + +private fun readTemplate(path: String): String = + ShaderGraphTemplates::class.java.getResourceAsStream(path)?.use { it.reader().readText() } + ?: error("The shader graph template is missing: $path") diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderLibrary.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderLibrary.kt new file mode 100644 index 000000000..2682cdc9d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderLibrary.kt @@ -0,0 +1,305 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +/** + * GLSL functions nodes share. A shader gets each one it needs once, after the ones it depends on. + */ +enum class ShaderLibrary(val code: String, vararg val requires: ShaderLibrary) { + HASH( + """ + float sg_hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + vec2 sg_hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); + } + """ + ), + + VALUE_NOISE( + """ + float sg_value_noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(sg_hash12(i), sg_hash12(i + vec2(1.0, 0.0)), f.x), + mix(sg_hash12(i + vec2(0.0, 1.0)), sg_hash12(i + vec2(1.0, 1.0)), f.x), + f.y + ); + } + """, + HASH, + ), + + GRADIENT_NOISE( + """ + float sg_gradient_noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = dot(sg_hash22(i) * 2.0 - 1.0, f); + float b = dot(sg_hash22(i + vec2(1.0, 0.0)) * 2.0 - 1.0, f - vec2(1.0, 0.0)); + float c = dot(sg_hash22(i + vec2(0.0, 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0)); + float d = dot(sg_hash22(i + vec2(1.0, 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 0.5 + 0.5; + } + """, + HASH, + ), + + FBM( + """ + float sg_fbm(vec2 p, float octaves, float roughness) { + float sum = 0.0; + float amplitude = 0.5; + float total = 0.0; + for (int octave = 0; octave < 8; octave++) { + if (float(octave) >= octaves) break; + sum += amplitude * sg_gradient_noise(p); + total += amplitude; + p = p * 2.03 + 17.1; + amplitude *= roughness; + } + return total > 0.0 ? sum / total : 0.0; + } + """, + GRADIENT_NOISE, + ), + + VORONOI( + """ + vec2 sg_voronoi(vec2 p, float jitter) { + vec2 cell = floor(p); + vec2 local = fract(p); + float nearest = 8.0; + float id = 0.0; + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 point = neighbor + sg_hash22(cell + neighbor) * jitter; + float gap = length(point - local); + if (gap < nearest) { + nearest = gap; + id = sg_hash12(cell + neighbor); + } + } + } + return vec2(nearest, id); + } + """, + HASH, + ), + + HASH3( + """ + float sg_hash13(vec3 p3) { + p3 = fract(p3 * 0.1031); + p3 += dot(p3, p3.zyx + 31.32); + return fract((p3.x + p3.y) * p3.z); + } + + vec3 sg_hash33(vec3 p3) { + p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yxz + 33.33); + return fract((p3.xxy + p3.yxx) * p3.zyx); + } + """ + ), + + VALUE_NOISE3( + """ + float sg_value_noise3(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix( + mix(sg_hash13(i), sg_hash13(i + vec3(1.0, 0.0, 0.0)), f.x), + mix(sg_hash13(i + vec3(0.0, 1.0, 0.0)), sg_hash13(i + vec3(1.0, 1.0, 0.0)), f.x), + f.y + ), + mix( + mix(sg_hash13(i + vec3(0.0, 0.0, 1.0)), sg_hash13(i + vec3(1.0, 0.0, 1.0)), f.x), + mix(sg_hash13(i + vec3(0.0, 1.0, 1.0)), sg_hash13(i + vec3(1.0, 1.0, 1.0)), f.x), + f.y + ), + f.z + ); + } + """, + HASH3, + ), + + GRADIENT_NOISE3( + """ + float sg_gradient_corner3(vec3 cell, vec3 local, vec3 corner) { + return dot(sg_hash33(cell + corner) * 2.0 - 1.0, local - corner); + } + + float sg_gradient_noise3(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * (3.0 - 2.0 * f); + return mix( + mix( + mix(sg_gradient_corner3(i, f, vec3(0.0, 0.0, 0.0)), sg_gradient_corner3(i, f, vec3(1.0, 0.0, 0.0)), u.x), + mix(sg_gradient_corner3(i, f, vec3(0.0, 1.0, 0.0)), sg_gradient_corner3(i, f, vec3(1.0, 1.0, 0.0)), u.x), + u.y + ), + mix( + mix(sg_gradient_corner3(i, f, vec3(0.0, 0.0, 1.0)), sg_gradient_corner3(i, f, vec3(1.0, 0.0, 1.0)), u.x), + mix(sg_gradient_corner3(i, f, vec3(0.0, 1.0, 1.0)), sg_gradient_corner3(i, f, vec3(1.0, 1.0, 1.0)), u.x), + u.y + ), + u.z + ) * 0.5 + 0.5; + } + """, + HASH3, + ), + + FBM3( + """ + float sg_fbm3(vec3 p, float octaves, float roughness) { + float sum = 0.0; + float amplitude = 0.5; + float total = 0.0; + for (int octave = 0; octave < 8; octave++) { + if (float(octave) >= octaves) break; + sum += amplitude * sg_gradient_noise3(p); + total += amplitude; + p = p * 2.03 + 17.1; + amplitude *= roughness; + } + return total > 0.0 ? sum / total : 0.0; + } + """, + GRADIENT_NOISE3, + ), + + VORONOI3( + """ + vec2 sg_voronoi3(vec3 p, float jitter) { + vec3 cell = floor(p); + vec3 local = fract(p); + float nearest = 8.0; + float id = 0.0; + for (int z = -1; z <= 1; z++) { + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec3 neighbor = vec3(float(x), float(y), float(z)); + vec3 point = neighbor + sg_hash33(cell + neighbor) * jitter; + float gap = length(point - local); + if (gap < nearest) { + nearest = gap; + id = sg_hash13(cell + neighbor); + } + } + } + } + return vec2(nearest, id); + } + """, + HASH3, + ), + + ROTATE( + """ + vec2 sg_rotate(vec2 uv, vec2 center, float degrees) { + float angle = radians(degrees); + float s = sin(angle); + float c = cos(angle); + vec2 p = uv - center; + return vec2(p.x * c - p.y * s, p.x * s + p.y * c) + center; + } + """ + ), + + POLAR( + """ + vec2 sg_polar(vec2 uv, vec2 center) { + vec2 p = uv - center; + return vec2(length(p) * 2.0, atan(p.y, p.x) / 6.28318530718 + 0.5); + } + """ + ), + + /** + * Normals worked out per pixel from how things change between neighboring pixels, so they only + * run in a fragment stage. + */ + NORMALS( + """ + vec3 sg_bump(vec3 normal, vec3 position, float height, float strength) { + vec3 dpdx = dFdx(position); + vec3 dpdy = dFdy(position); + float dhdx = dFdx(height); + float dhdy = dFdy(height); + vec3 r1 = cross(dpdy, normal); + vec3 r2 = cross(normal, dpdx); + float det = dot(dpdx, r1); + vec3 gradient = sign(det) * (dhdx * r1 + dhdy * r2); + return normalize(abs(det) * normal - strength * gradient); + } + + vec3 sg_surface_normal(vec3 position, vec3 view) { + vec3 normal = normalize(cross(dFdx(position), dFdy(position))); + return dot(normal, view) < 0.0 ? -normal : normal; + } + """ + ), + + /** + * Signed distances to shapes centered on the surface, negative inside. `p` runs from -1 to 1 + * across the surface and `size` is the half extent of the shape in the same units. + */ + SHAPES( + """ + float sg_shape_ellipse(vec2 p, vec2 size) { + return (length(p / size) - 1.0) * min(size.x, size.y); + } + + float sg_shape_rectangle(vec2 p, vec2 size, float radius) { + float r = clamp(radius, 0.0, 1.0) * min(size.x, size.y); + vec2 d = abs(p) - size + r; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - r; + } + + float sg_shape_polygon(vec2 p, vec2 size, float sides) { + float n = max(floor(sides), 3.0); + float half_angle = 3.14159265 / n; + vec2 q = p / size; + float angle = mod(atan(q.x, q.y) + half_angle, 2.0 * half_angle) - half_angle; + return (length(q) * cos(angle) - cos(half_angle)) * min(size.x, size.y); + } + + float sg_shape_ring(vec2 p, vec2 size, float thickness) { + return abs(sg_shape_ellipse(p, size)) - thickness * 0.5; + } + + float sg_shape_mask(float distance, float softness) { + float m = clamp(-distance / max(softness, 0.0001), 0.0, 1.0); + return m * m * (3.0 - 2.0 * m); + } + """ + ); + + companion object { + /** [libraries] and everything they need, each once, dependencies first. */ + fun ordered(libraries: Collection): List { + val out = LinkedHashSet() + fun visit(library: ShaderLibrary) { + if (library in out) return + library.requires.forEach(::visit) + out += library + } + libraries.sortedBy { it.ordinal }.forEach(::visit) + return out.toList() + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderMathNodes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderMathNodes.kt new file mode 100644 index 000000000..37a3926ba --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderMathNodes.kt @@ -0,0 +1,298 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeCategory.* +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.* + +/** Arithmetic, vectors and patterns: the nodes that compute rather than read. */ +internal object ShaderMathNodes { + const val EXPRESSION = "hollowengine:math/expression" + const val SHAPE = "hollowengine:procedural/shape" + + /** What a new expression node computes. */ + const val DEFAULT_EXPRESSION = "a * b" + + val all: List by lazy { math() + vector() + procedural() } + + private fun math() = listOf( + binary("add", "arithmetic", 0f, 0f) { a, b -> "$a + $b" }, + binary("subtract", "arithmetic", 0f, 0f) { a, b -> "$a - $b" }, + binary("multiply", "arithmetic", 1f, 1f) { a, b -> "$a * $b" }, + binary("divide", "arithmetic", 1f, 1f) { a, b -> "$a / $b" }, + binary("power", "arithmetic", 1f, 2f) { a, b -> "pow($a, $b)" }, + unary("square_root", "arithmetic") { "sqrt($it)" }, + unary("one_minus", "arithmetic") { "1.0 - $it" }, + unary("negate", "arithmetic") { "-$it" }, + unary("absolute", "arithmetic") { "abs($it)" }, + + binary("minimum", "range", 0f, 0f) { a, b -> "min($a, $b)" }, + binary("maximum", "range", 0f, 0f) { a, b -> "max($a, $b)" }, + binary("modulo", "range", 0f, 1f) { a, b -> "mod($a, $b)" }, + shaderNode("hollowengine:math/clamp", MATH) { + group("range") + val value = input("In", 0f) + val min = input("Min", 0f) + val max = input("Max", 1f) + output("Out") { "clamp(${value.code}, ${min.code}, ${max.code})" } + }, + unary("saturate", "range") { "clamp($it, 0.0, 1.0)" }, + unary("floor", "range") { "floor($it)" }, + unary("fraction", "range") { "fract($it)" }, + shaderNode("hollowengine:math/remap", MATH) { + group("range") + val value = input("In", 0.5f) + val inMin = input("In Min", 0f) + val inMax = input("In Max", 1f) + val outMin = input("Out Min", 0f) + val outMax = input("Out Max", 1f) + output("Out") { + "${outMin.code} + (${value.code} - ${inMin.code}) * (${outMax.code} - ${outMin.code}) / (${inMax.code} - ${inMin.code})" + } + }, + + shaderNode("hollowengine:math/lerp", MATH) { + group("interpolation") + val a = input("A", 0f) + val b = input("B", 1f) + val t = input("T", 0.5f) + output("Out") { "mix(${a.code}, ${b.code}, ${t.code})" } + }, + shaderNode("hollowengine:math/step", MATH) { + group("interpolation") + val edge = input("Edge", 0.5f) + val value = input("In", 0f) + output("Out") { "step(${edge.code}, ${value.code})" } + }, + shaderNode("hollowengine:math/smoothstep", MATH) { + group("interpolation") + val from = input("Edge 1", 0f) + val to = input("Edge 2", 1f) + val value = input("In", 0.5f) + output("Out") { "smoothstep(${from.code}, ${to.code}, ${value.code})" } + }, + + unary("sine", "trigonometry") { "sin($it)" }, + unary("cosine", "trigonometry") { "cos($it)" }, + + expression(), + ) + + /** + * GLSL typed on the node: every free name of the text is a pin, so `(sin(x) + 1) / 2` is a node + * with an input `x`. A pin named like an engine input, such as `uv` or `time`, reads that input + * while nothing is linked to it. + */ + private fun expression() = shaderNode(EXPRESSION, MATH) { + group("expression") + expression("expression", DEFAULT_EXPRESSION) + option("type", "auto", "float", "vec2", "vec3", "vec4") + pins { node -> + ShaderExpression.parse(node.options["expression"] ?: DEFAULT_EXPRESSION).variables.map { name -> + ShaderPinSpec(name, ShaderPinType.ANY, listOf(0f), ShaderInput.forExpression(name)) + } + } + check { node -> + ShaderExpression.parse(node.options["expression"] ?: DEFAULT_EXPRESSION).error?.let { error -> + ShaderDiagnostic( + ShaderProblem.INVALID_EXPRESSION, + node.id, + error.detail, + reason = error.problem.name.lowercase() + ) + } + } + // Left on auto, the type is read off the text: `length(v)` is a number, `v * 2` as wide as `v`. + output("Out", typeOf = { + when (node.options["type"]) { + "float" -> FLOAT + "vec2" -> VEC2 + "vec3" -> VEC3 + "vec4" -> VEC4 + else -> ShaderExpression.parse(node.options["expression"] ?: DEFAULT_EXPRESSION) + .width { name -> input(name)?.width }?.let { ShaderPinType.of(ShaderType.ofWidth(it)) } ?: FLOAT + } + }) { + ShaderExpression.parse(option("expression")).glsl(::input) ?: shaderLiteral(outputType, listOf(0f)) + } + } + + private fun vector() = listOf( + shaderNode("hollowengine:vector/length", VECTOR) { + group("measure") + val value = input("In", 0f) + output("Out", FLOAT) { "length(${value.code})" } + }, + shaderNode("hollowengine:vector/distance", VECTOR) { + group("measure") + val a = input("A", 0f) + val b = input("B", 0f) + output("Out", FLOAT) { "distance(${a.code}, ${b.code})" } + }, + shaderNode("hollowengine:vector/dot", VECTOR) { + group("measure") + val a = input("A", 0f) + val b = input("B", 0f) + output("Out", FLOAT) { "dot(${a.code}, ${b.code})" } + }, + shaderNode("hollowengine:vector/cross", VECTOR) { + group("measure") + val a = input("A", 1f, 0f, 0f, type = VEC3) + val b = input("B", 0f, 1f, 0f, type = VEC3) + output("Out", VEC3) { "cross(${a.code}, ${b.code})" } + }, + shaderNode("hollowengine:vector/normalize", VECTOR) { + group("measure") + val value = input("In", 0f, 1f, 0f) + output("Out") { "normalize(${value.code})" } + }, + shaderNode("hollowengine:vector/split", VECTOR) { + group("channels") + noPreview() + val value = input("In", 0f, 0f, 0f, 0f, type = VEC4) + output("R", FLOAT) { "${value.code}.x" } + output("G", FLOAT) { "${value.code}.y" } + output("B", FLOAT) { "${value.code}.z" } + output("A", FLOAT) { "${value.code}.w" } + }, + shaderNode("hollowengine:vector/combine", VECTOR) { + group("channels") + val r = input("R", 0f, type = FLOAT) + val g = input("G", 0f, type = FLOAT) + val b = input("B", 0f, type = FLOAT) + val a = input("A", 1f, type = FLOAT) + output("RGBA", VEC4) { "vec4(${r.code}, ${g.code}, ${b.code}, ${a.code})" } + output("RGB", VEC3) { "${output("RGBA")}.rgb" } + output("RG", VEC2) { "${output("RGBA")}.rg" } + }, + shaderNode("hollowengine:vector/swizzle", VECTOR) { + group("channels") + text("mask", default = "xyz") + val value = input("In", 0f, 0f, 0f, 0f, type = VEC4) + output("Out", { ShaderPinType.of(ShaderType.ofWidth(swizzleMask(node.options["mask"] ?: "xyz").length)) }) { + "${value.code}.${swizzleMask(option("mask"))}" + } + }, + ) + + private fun procedural() = listOf( + noise("value_noise", ShaderLibrary.VALUE_NOISE, ShaderLibrary.VALUE_NOISE3, "sg_value_noise"), + noise("gradient_noise", ShaderLibrary.GRADIENT_NOISE, ShaderLibrary.GRADIENT_NOISE3, "sg_gradient_noise"), + shaderNode("hollowengine:procedural/fbm", PROCEDURAL) { + group("noise") + uses(ShaderLibrary.FBM, ShaderLibrary.FBM3) + val uv = input("UV", fallback = ShaderInput.UV) + val scale = input("Scale", 4f, type = FLOAT) + val octaves = input("Octaves", 4f, type = FLOAT) + val roughness = input("Roughness", 0.5f, type = FLOAT) + output("Out", FLOAT) { noiseCall("sg_fbm", dynamic, uv.code, scale.code, octaves.code, roughness.code) } + }, + shaderNode("hollowengine:procedural/voronoi", PROCEDURAL) { + group("noise") + uses(ShaderLibrary.VORONOI, ShaderLibrary.VORONOI3) + val uv = input("UV", fallback = ShaderInput.UV) + val scale = input("Scale", 5f, type = FLOAT) + val jitter = input("Jitter", 1f, type = FLOAT) + output("Distance", FLOAT) { noiseCall("sg_voronoi", dynamic, uv.code, scale.code, jitter.code) + ".x" } + output("Cell", FLOAT) { noiseCall("sg_voronoi", dynamic, uv.code, scale.code, jitter.code) + ".y" } + }, + shape(), + shaderNode("hollowengine:procedural/fresnel", PROCEDURAL) { + group("lighting") + val normal = input("Normal", type = VEC3, fallback = ShaderInput.NORMAL) + val view = input("View", type = VEC3, fallback = ShaderInput.VIEW_DIRECTION) + val power = input("Power", 5f, type = FLOAT) + output("Out", FLOAT) { + "pow(1.0 - clamp(abs(dot(normalize(${normal.code}), normalize(${view.code}))), 0.0, 1.0), ${power.code})" + } + }, + ) + + /** + * A shape centered on the surface: a circle for a particle, a long rectangle for a beam. Width and + * height are fractions of the surface; the inputs a shape does not use are not there. + */ + private fun shape() = shaderNode(SHAPE, PROCEDURAL) { + group("shape") + uses(ShaderLibrary.SHAPES) + option("shape", "ellipse", "rectangle", "polygon", "ring") + fun shapeIs(vararg names: String): (ShaderGraphNode) -> Boolean = + { node -> (node.options["shape"] ?: "ellipse") in names } + + val uv = input("UV", type = VEC2, fallback = ShaderInput.UV) + val width = input("Width", 1f, type = FLOAT) + val height = input("Height", 1f, type = FLOAT) + val roundness = input("Roundness", 0f, type = FLOAT, shownFor = shapeIs("rectangle")) + val sides = input("Sides", 6f, type = FLOAT, shownFor = shapeIs("polygon")) + val thickness = input("Thickness", 0.1f, type = FLOAT, shownFor = shapeIs("ring")) + val softness = input("Softness", 0.02f, type = FLOAT) + output("Distance", FLOAT) { + val p = "(${uv.code} * 2.0 - 1.0)" + val size = "vec2(${width.code}, ${height.code})" + when (option("shape")) { + "rectangle" -> "sg_shape_rectangle($p, $size, ${roundness.code})" + "polygon" -> "sg_shape_polygon($p, $size, ${sides.code})" + "ring" -> "sg_shape_ring($p, $size, ${thickness.code})" + else -> "sg_shape_ellipse($p, $size)" + } + } + output("Mask", FLOAT) { "sg_shape_mask(${output("Distance")}, ${softness.code})" } + } + + private fun binary(name: String, group: String, a: Float, b: Float, expression: (String, String) -> String) = + shaderNode("hollowengine:math/$name", MATH) { + group(group) + val left = input("A", a) + val right = input("B", b) + output("Out") { expression(left.code, right.code) } + } + + private fun unary(name: String, group: String, expression: (String) -> String) = + shaderNode("hollowengine:math/$name", MATH) { + group(group) + val value = input("In", 0f) + output("Out") { expression("(${value.code})") } + } + + /** A noise over a UV, or over a position in 3D, whichever its coordinates are. */ + private fun noise(name: String, flat: ShaderLibrary, solid: ShaderLibrary, function: String) = + shaderNode("hollowengine:procedural/$name", PROCEDURAL) { + group("noise") + uses(flat, solid) + val uv = input("UV", fallback = ShaderInput.UV) + val scale = input("Scale", 8f, type = FLOAT) + output("Out", FLOAT) { noiseCall(function, dynamic, uv.code, scale.code) } + } + + /** + * A call of a noise [function] on [coordinates] of [type]: its 3D version, suffixed `3`, for a + * position, the 2D one for a UV; a number is read as a UV along x. + */ + private fun noiseCall( + function: String, + type: ShaderType, + coordinates: String, + scale: String, + vararg rest: String, + ): String { + val tail = rest.joinToString("") { ", $it" } + return when (type.width) { + 1 -> "$function(vec2($coordinates, 0.0) * $scale$tail)" + 2 -> "$function($coordinates * $scale$tail)" + 3 -> "${function}3($coordinates * $scale$tail)" + else -> "${function}3(($coordinates).xyz * $scale$tail)" + } + } +} + +/** A swizzle mask as GLSL takes it: one to four of `xyzw`, in `rgba` too; anything else reads `x`. */ +internal fun swizzleMask(mask: String?): String { + val normalized = mask.orEmpty().lowercase().map { char -> + when (char) { + 'r' -> 'x' + 'g' -> 'y' + 'b' -> 'z' + 'a' -> 'w' + else -> char + } + }.joinToString("") + return normalized.takeIf { it.length in 1..4 && it.all { char -> char in "xyzw" } } ?: "x" +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNodeLibrary.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNodeLibrary.kt new file mode 100644 index 000000000..019d6260a --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNodeLibrary.kt @@ -0,0 +1,215 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeCategory.* +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeCategory.TEXTURE +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.FLOAT +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.VEC2 +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.VEC3 +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.VEC4 + +/** The kinds of node the engine ships, in the order the add menu lists them. */ +object ShaderNodeLibrary { + const val SURFACE_OUTPUT = "hollowengine:output/surface" + const val PROPERTY = "hollowengine:input/property" + const val EXPRESSION = ShaderMathNodes.EXPRESSION + + val all: List by lazy { inputs() + uv() + texture() + output() + ShaderMathNodes.all + ShaderNormalNodes.all } + + /** + * What a new surface graph starts as: the material texture times the particle color, which is what + * a material without a graph draws, so a new graph changes nothing until it is edited. + */ + fun defaultSurface() = ShaderGraph( + nodes = listOf( + ShaderGraphNode("texture", "hollowengine:texture/sample", x = -520f, y = -40f), + ShaderGraphNode("color", "hollowengine:input/vertex_color", x = -520f, y = 190f), + ShaderGraphNode("tint", "hollowengine:math/multiply", x = -300f, y = 0f), + ShaderGraphNode("alpha", "hollowengine:vector/split", x = -300f, y = 190f), + ShaderGraphNode("output", SURFACE_OUTPUT, x = -60f, y = 0f), + ), + links = listOf( + ShaderGraphLink("texture", "RGBA", "tint", "A"), + ShaderGraphLink("color", "RGBA", "tint", "B"), + ShaderGraphLink("tint", "Out", "output", SurfaceOutputs.COLOR), + ShaderGraphLink("tint", "Out", "alpha", "In"), + ShaderGraphLink("alpha", "A", "output", SurfaceOutputs.ALPHA), + ), + ) + + private fun inputs() = listOf( + engineInput("uv", ShaderInput.UV, "UV"), + engineInput("texture_uv", ShaderInput.TEXTURE_UV, "UV"), + engineInput("position", ShaderInput.POSITION, "Position"), + engineInput("object_position", ShaderInput.OBJECT_POSITION, "Position"), + engineInput("normal", ShaderInput.NORMAL, "Normal"), + engineInput("view_direction", ShaderInput.VIEW_DIRECTION, "Direction"), + engineInput("screen_uv", ShaderInput.SCREEN_UV, "UV"), + + shaderNode("hollowengine:input/vertex_color", INPUT) { + group("surface") + noPreview() + reads(ShaderInput.COLOR) + output("RGBA", VEC4) { ShaderInput.COLOR.glsl } + output("RGB", VEC3) { "${ShaderInput.COLOR.glsl}.rgb" } + output("A", FLOAT) { "${ShaderInput.COLOR.glsl}.a" } + }, + + shaderNode("hollowengine:input/time", INPUT) { + group("time") + noPreview() + reads(ShaderInput.TIME) + output("Time", FLOAT) { ShaderInput.TIME.glsl } + output("Sine", FLOAT) { "sin(${ShaderInput.TIME.glsl})" } + }, + + shaderNode("hollowengine:input/scene_depth", INPUT) { + group("scene") + noPreview() + reads(ShaderInput.SCENE_DEPTH, ShaderInput.FRAGMENT_DEPTH) + fragmentOnly() + output("Scene", FLOAT) { ShaderInput.SCENE_DEPTH.glsl } + output("Fragment", FLOAT) { ShaderInput.FRAGMENT_DEPTH.glsl } + output("Difference", FLOAT) { "${output("Scene")} - ${output("Fragment")}" } + }, + + shaderNode("hollowengine:input/scene_color", INPUT) { + group("scene") + noPreview() + reads(ShaderInput.SCENE_COLOR) + fragmentOnly() + val uv = input("UV", type = VEC2, fallback = ShaderInput.SCREEN_UV) + output("RGB", VEC3) { "${ShaderInput.SCENE_COLOR.glsl}(${uv.code})" } + }, + + constant("float", FLOAT, 0f), + constant("vector2", VEC2, 0f, 0f), + constant("vector3", VEC3, 0f, 0f, 0f), + constant("vector4", VEC4, 0f, 0f, 0f, 0f), + shaderNode("hollowengine:input/color", INPUT) { + group("value") + noPreview() + val color = input("Color", 1f, 1f, 1f, 1f, type = VEC4, color = true) + output("RGBA", VEC4) { color.code } + output("RGB", VEC3) { "${output("RGBA")}.rgb" } + }, + shaderNode(PROPERTY, INPUT) { + group("value") + noPreview() + property("property") + output("Out", { graph.property(node.options["property"].orEmpty())?.type?.let(ShaderPinType::of) }) { + propertyUniform(option("property")) + } + }, + shaderNode("hollowengine:input/main_texture", INPUT) { + group("value") + reads(ShaderInput.MAIN_TEXTURE) + output("Texture", ShaderPinType.TEXTURE) { ShaderInput.MAIN_TEXTURE.glsl } + }, + ) + + private fun uv() = listOf( + shaderNode("hollowengine:uv/tiling_offset", UV) { + val uv = input("UV", type = VEC2, fallback = ShaderInput.UV) + val tiling = input("Tiling", 1f, 1f, type = VEC2) + val offset = input("Offset", 0f, 0f, type = VEC2) + output("Out", VEC2) { "${uv.code} * ${tiling.code} + ${offset.code}" } + }, + shaderNode("hollowengine:uv/rotate", UV) { + uses(ShaderLibrary.ROTATE) + val uv = input("UV", type = VEC2, fallback = ShaderInput.UV) + val center = input("Center", 0.5f, 0.5f, type = VEC2) + val degrees = input("Degrees", 0f, type = FLOAT) + output("Out", VEC2) { "sg_rotate(${uv.code}, ${center.code}, ${degrees.code})" } + }, + shaderNode("hollowengine:uv/polar", UV) { + uses(ShaderLibrary.POLAR) + val uv = input("UV", type = VEC2, fallback = ShaderInput.UV) + val center = input("Center", 0.5f, 0.5f, type = VEC2) + output("Out", VEC2) { "sg_polar(${uv.code}, ${center.code})" } + }, + shaderNode("hollowengine:uv/scroll", UV) { + reads(ShaderInput.TIME) + val uv = input("UV", type = VEC2, fallback = ShaderInput.UV) + val speed = input("Speed", 0f, 0.5f, type = VEC2) + output("Out", VEC2) { "${uv.code} + ${speed.code} * ${ShaderInput.TIME.glsl}" } + }, + ) + + private fun texture() = listOf( + shaderNode("hollowengine:texture/sample", TEXTURE) { + toggle("flip_x") + toggle("flip_y") + reads(ShaderInput.FRAME_UV) + val texture = input("Texture", type = ShaderPinType.TEXTURE, fallback = ShaderInput.MAIN_TEXTURE) + val uv = input("UV", type = VEC2, fallback = ShaderInput.TEXTURE_UV) + output("RGBA", VEC4) { + val at = sampledUv(uv.code, uv.linked, toggle("flip_x"), toggle("flip_y")) + if (vertex) "textureLod(${texture.code}, $at, 0.0)" else "texture(${texture.code}, $at)" + } + output("RGB", VEC3) { "${output("RGBA")}.rgb" } + output("A", FLOAT) { "${output("RGBA")}.a" } + }, + ) + + /** + * Where a sample reads: [uv] as it is, or turned over. With nothing linked the surface UV is turned + * over and then mapped into the texture, so a flipbook frame turns over in place. + */ + private fun sampledUv(uv: String, linked: Boolean, flipX: Boolean, flipY: Boolean): String { + if (!flipX && !flipY) return uv + fun flip(of: String): String { + val x = if (flipX) "1.0 - $of.x" else "$of.x" + val y = if (flipY) "1.0 - $of.y" else "$of.y" + return "vec2($x, $y)" + } + return if (linked) flip("($uv)") else "${ShaderInput.FRAME_UV.glsl}(${flip(ShaderInput.UV.glsl)})" + } + + private fun output() = listOf( + shaderNode(SURFACE_OUTPUT, OUTPUT) { + master(ShaderTarget.SURFACE, SurfaceOutputs.VERTEX_OFFSET) + input(SurfaceOutputs.COLOR, 1f, 1f, 1f, type = VEC3, color = true) + input(SurfaceOutputs.ALPHA, 1f, type = FLOAT) + input(SurfaceOutputs.EMISSION, 0f, 0f, 0f, type = VEC3, color = true) + input(SurfaceOutputs.ALPHA_CLIP, 0f, type = FLOAT) + input(SurfaceOutputs.VERTEX_OFFSET, 0f, 0f, 0f, type = VEC3) + }, + ) + + /** A node that hands out one thing the engine gives every graph. */ + private fun engineInput(name: String, input: ShaderInput, output: String) = + shaderNode("hollowengine:input/$name", INPUT) { + group("surface") + reads(input) + if (input.fragmentOnly) fragmentOnly() + if (input == ShaderInput.NORMAL || input == ShaderInput.VIEW_DIRECTION) preview(ShaderPreviewStyle.SIGNED) + output(output, ShaderPinType.of(input.type)) { input.glsl } + } + + /** A value typed in on the node itself: an input with nothing to link, passed straight out. */ + private fun constant(name: String, type: ShaderPinType, vararg default: Float) = + shaderNode("hollowengine:input/$name", INPUT) { + group("value") + noPreview() + val value = input("Value", *default, type = type) + output("Out", type) { value.code } + } +} + +/** The inputs of the surface output node, which the templates read back by these names. */ +object SurfaceOutputs { + const val COLOR = "Color" + const val ALPHA = "Alpha" + + /** What glows in the glow pass; left unlinked, the color does, as with a material that has no graph. */ + const val EMISSION = "Emission" + + /** Fragments with less alpha than this are dropped. */ + const val ALPHA_CLIP = "Alpha Clip" + + /** Blocks the vertex moves by, in the space of the view. */ + const val VERTEX_OFFSET = "Vertex Offset" +} + +/** The uniform a property reads, named so it cannot clash with anything of the engine's. */ +fun propertyUniform(property: String): String = "p_" + property.filter { it.isLetterOrDigit() || it == '_' } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNodeType.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNodeType.kt new file mode 100644 index 000000000..7056fbb0d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNodeType.kt @@ -0,0 +1,365 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.minecraft.server.packs.resources.ResourceManager +import net.minecraft.server.packs.resources.ResourceManagerReloadListener +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.common.events.ClientEvent +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.utils.rl + +/** How a preview shows the first output of a node. */ +enum class ShaderPreviewStyle { + /** As a color: a number is gray, a vector its components. */ + VALUE, + + /** A direction, whose components run from -1 to 1, shown `* 0.5 + 0.5`, as a normal map is. */ + SIGNED, + + /** An offset: the mesh moved by it, shaded, since a few hundredths of a block show as black. */ + DISPLACEMENT, +} + +/** Where a kind of node shows up in the add menu. */ +enum class ShaderNodeCategory { + INPUT, MATH, VECTOR, NORMAL, UV, TEXTURE, PROCEDURAL, OUTPUT, +} + +/** An input of a kind of node: what it takes, and what it is when nothing is linked to it. */ +class ShaderPinSpec( + val name: String, + val type: ShaderPinType, + val default: List, + val fallback: ShaderInput? = null, + val shownFor: ((ShaderGraphNode) -> Boolean)? = null, + val color: Boolean = false, +) + +/** + * What the type of an output can be worked out from: the node as placed, in its graph, and the types + * its inputs resolved to. + */ +class ShaderTypeContext internal constructor( + val graph: ShaderGraph, + val node: ShaderGraphNode, + private val inputs: Map, +) { + fun input(name: String): ShaderType? = inputs[name] +} + +/** + * An output of a kind of node. Its type is [type], or what [typeOf] says for the node as placed, + * which is how a property node takes the type of its property; [typeOf] giving null means the type + * cannot be worked out. + */ +class ShaderOutputSpec( + val name: String, + val type: ShaderPinType, + val typeOf: (ShaderTypeContext.() -> ShaderPinType?)?, + val expression: ShaderEmitContext.() -> String, +) + +/** How a choice a node offers is edited. */ +enum class ShaderOptionKind { + /** One of [ShaderOptionSpec.values]. */ + CHOICE, + + /** On or off, stored as `true` and `false`. */ + TOGGLE, + + /** Free text. */ + TEXT, + + /** GLSL, as the expression node takes it. */ + EXPRESSION, + + /** One of the properties of the graph. */ + PROPERTY, +} + +/** A choice a node offers besides its inputs. */ +class ShaderOptionSpec(val name: String, val kind: ShaderOptionKind, val values: List, val default: String) { + val titleKey: String get() = "hollowengine.gui.shadergraph.option.$name" + + fun valueKey(value: String): String = "hollowengine.gui.shadergraph.option.$name.$value" +} + +/** What makes a kind of node the output of a graph: which target, and which of its inputs feed the vertex stage. */ +class ShaderMasterSpec(val target: ShaderTarget, val vertexInputs: Set) + +/** + * One kind of node: its pins and options, and the GLSL each output is. Kinds are made with + * [shaderNode] and live in [ShaderNodeTypes], so an addon can add its own. + */ +class ShaderNodeType( + val id: String, + val category: ShaderNodeCategory, + val group: String, + private val declaredInputs: List, + private val pinsOf: ((ShaderGraphNode) -> List)?, + val outputs: List, + val options: List, + val reads: Set, + val libraries: Set, + val fragmentOnly: Boolean, + val master: ShaderMasterSpec?, + val previewByDefault: Boolean, + val previewStyle: ShaderPreviewStyle, + private val check: ((ShaderGraphNode) -> ShaderDiagnostic?)?, +) { + val titleKey: String get() = "hollowengine.gui.shadergraph.node.${id.substringAfter(':').replace('/', '.')}" + + val prototype: ShaderGraphNode get() = ShaderGraphNode("", id) + + fun inputs(node: ShaderGraphNode): List = + pinsOf?.invoke(node) ?: declaredInputs.filter { it.shownFor?.invoke(node) != false } + + fun codeInputs(node: ShaderGraphNode): List = pinsOf?.invoke(node) ?: declaredInputs + + fun input(node: ShaderGraphNode, name: String): ShaderPinSpec? = inputs(node).firstOrNull { it.name == name } + + fun output(name: String): ShaderOutputSpec? = outputs.firstOrNull { it.name == name } + fun option(name: String): ShaderOptionSpec? = options.firstOrNull { it.name == name } + + fun problem(node: ShaderGraphNode): ShaderDiagnostic? = check?.invoke(node) + + fun showsPreview(node: ShaderGraphNode): Boolean = + (outputs.isNotEmpty() || master != null) && (node.preview ?: previewByDefault) +} + +/** + * What an output expression is written against: the GLSL of each input, already of the type the pin + * resolved to, the node's options, and the variables of the node's earlier outputs. + */ +class ShaderEmitContext internal constructor( + val graph: ShaderGraph, + val node: ShaderGraphNode, + val dynamic: ShaderType, + val outputType: ShaderType, + val vertex: Boolean, + private val inputCode: Map, + private val outputVariables: Map, + private val kind: ShaderNodeType, +) { + /** The GLSL of this input. */ + val ShaderPinSpec.code: String get() = inputCode.getValue(name) + + /** The GLSL of the input named [name]. */ + fun input(name: String): String = inputCode.getValue(name) + + /** Whether something is linked into this input, rather than it taking its value or its fallback. */ + val ShaderPinSpec.linked: Boolean get() = graph.linkInto(node.id, name) != null && kind.inputs(node).any { it.name == name } + + /** The variable an earlier output of this node was written to. */ + fun output(name: String): String = outputVariables.getValue(name) + + fun option(name: String): String = node.options[name] ?: kind.option(name)?.default.orEmpty() + + fun toggle(name: String): Boolean = option(name) == "true" +} + +/** Builds a kind of node; see [shaderNode]. */ +class ShaderNodeBuilder internal constructor(private val id: String, private val category: ShaderNodeCategory) { + private val inputs = ArrayList() + private val outputs = ArrayList() + private val options = ArrayList() + private val reads = LinkedHashSet() + private val libraries = LinkedHashSet() + private var group = "" + private var pinsOf: ((ShaderGraphNode) -> List)? = null + private var check: ((ShaderGraphNode) -> ShaderDiagnostic?)? = null + private var fragmentOnly = false + private var preview = true + private var previewStyle = ShaderPreviewStyle.VALUE + private var master: ShaderMasterSpec? = null + + fun input( + name: String, + vararg default: Float, + type: ShaderPinType = ShaderPinType.DYNAMIC, + fallback: ShaderInput? = null, + shownFor: ((ShaderGraphNode) -> Boolean)? = null, + color: Boolean = false, + ): ShaderPinSpec = ShaderPinSpec(name, type, default.toList(), fallback, shownFor, color).also { inputs += it } + + /** Works the inputs out of the node itself, in place of the ones declared with [input]. */ + fun pins(of: (ShaderGraphNode) -> List) { + pinsOf = of + } + + fun output(name: String, type: ShaderPinType = ShaderPinType.DYNAMIC, expression: ShaderEmitContext.() -> String) { + outputs += ShaderOutputSpec(name, type, null, expression) + } + + /** An output whose type depends on how the node is set up rather than on its inputs. */ + fun output( + name: String, + typeOf: ShaderTypeContext.() -> ShaderPinType?, + expression: ShaderEmitContext.() -> String, + ) { + outputs += ShaderOutputSpec(name, ShaderPinType.DYNAMIC, typeOf, expression) + } + + fun option(name: String, vararg values: String, default: String = values.firstOrNull().orEmpty()) { + options += ShaderOptionSpec(name, ShaderOptionKind.CHOICE, values.toList(), default) + } + + fun toggle(name: String, default: Boolean = false) { + options += ShaderOptionSpec(name, ShaderOptionKind.TOGGLE, listOf("false", "true"), default.toString()) + } + + fun text(name: String, default: String = "") { + options += ShaderOptionSpec(name, ShaderOptionKind.TEXT, emptyList(), default) + } + + fun expression(name: String, default: String) { + options += ShaderOptionSpec(name, ShaderOptionKind.EXPRESSION, emptyList(), default) + } + + fun property(name: String) { + options += ShaderOptionSpec(name, ShaderOptionKind.PROPERTY, emptyList(), "") + } + + /** Reports what is wrong with a node as it is set up; see [ShaderNodeType.problem]. */ + fun check(problem: (ShaderGraphNode) -> ShaderDiagnostic?) { + check = problem + } + + fun group(name: String) { + group = name + } + + /** New nodes of this kind start with their preview off. */ + fun noPreview() { + preview = false + } + + /** How the preview shows what the node computes, when a plain color says little. */ + fun preview(style: ShaderPreviewStyle) { + previewStyle = style + } + + fun reads(vararg inputs: ShaderInput) { + reads += inputs + } + + fun uses(vararg library: ShaderLibrary) { + libraries += library + } + + fun fragmentOnly() { + fragmentOnly = true + } + + fun master(target: ShaderTarget, vararg vertexInputs: String) { + master = ShaderMasterSpec(target, vertexInputs.toSet()) + } + + internal fun build() = ShaderNodeType( + id = id, + category = category, + group = group, + declaredInputs = inputs.toList(), + pinsOf = pinsOf, + outputs = outputs.toList(), + options = options.toList(), + reads = reads.toSet(), + libraries = libraries.toSet(), + fragmentOnly = fragmentOnly, + master = master, + previewByDefault = preview, + previewStyle = previewStyle, + check = check, + ) +} + +/** + * A kind of node, declared the way it reads: + * + * ``` + * shaderNode("hollowengine:math/multiply", ShaderNodeCategory.MATH) { + * val a = input("A", 1f) + * val b = input("B", 1f) + * output("Out") { "${a.code} * ${b.code}" } + * } + * ``` + */ +fun shaderNode(id: String, category: ShaderNodeCategory, build: ShaderNodeBuilder.() -> Unit): ShaderNodeType = + ShaderNodeBuilder(id, category).apply(build).build() + +/** + * Every kind of node a graph can use: the engine's, the ones addons register once, and the ones + * [RegisterShaderNodesEvent] brings on every resource reload. + */ +object ShaderNodeTypes { + val point = ExtensionPoints.create("hollowengine:shadergraph/node_types".rl) + + /** What the last [RegisterShaderNodesEvent] registered, taken back on the next. */ + private var reloaded: List = emptyList() + + /** Bumped on every reload, so an open editor compiles its graph again against the kinds there are now. */ + var revision by mutableStateOf(0) + private set + + init { + ShaderNodeLibrary.all.forEach(::register) + } + + fun register(type: ShaderNodeType): ExtensionHandle = point.register(type.id.rl, type) + + /** Every kind, in the order they were registered, which is the order the add menu lists them in. */ + val all: List get() = point.extensions + + fun of(id: String): ShaderNodeType? = all.firstOrNull { it.id == id } + + /** The output node kind of [target]. */ + fun master(target: ShaderTarget): ShaderNodeType? = all.firstOrNull { it.master?.target == target } + + /** + * Takes back what the previous reload registered and asks again. The engine's kinds go in first, + * so one a script replaced last time is back if the script no longer replaces it. + */ + fun reload() { + reloaded.forEach(ExtensionHandle::close) + ShaderNodeLibrary.all.forEach(::register) + val event = RegisterShaderNodesEvent.post(RegisterShaderNodesEvent()) + reloaded = event.types.map(::register) + revision++ + } +} + +/** + * Fires on every resource reload of the client: where a `reload.kts` or an addon adds kinds of node + * to the shader graph. What it registered on the previous reload is taken back first, so a script + * that stops registering a kind loses it, and one that registers the id of an engine kind replaces it. + * + * ``` + * @SubscribeEvent + * fun nodes(event: RegisterShaderNodesEvent) { + * event.register("mymod:math/double", ShaderNodeCategory.MATH) { + * val value = input("In", 1f) + * output("Out") { "${value.code} * 2.0" } + * } + * } + * ``` + */ +class RegisterShaderNodesEvent : ClientEvent { + internal val types = ArrayList() + + fun register(type: ShaderNodeType) { + types += type + } + + fun register(id: String, category: ShaderNodeCategory, build: ShaderNodeBuilder.() -> Unit) = + register(shaderNode(id, category, build)) + + companion object : EventHandler() +} + +/** Asks for the kinds of node again whenever the client reloads its resources. */ +object ShaderNodeReloadListener : ResourceManagerReloadListener { + override fun onResourceManagerReload(resourceManager: ResourceManager) = ShaderNodeTypes.reload() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNormalNodes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNormalNodes.kt new file mode 100644 index 000000000..4e67a511d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderNormalNodes.kt @@ -0,0 +1,66 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeCategory.NORMAL +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.FLOAT +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinType.VEC3 + +/** + * Normals, moving the surface, and light that shows both. Normals and positions are in the axes of + * the world, the position relative to the camera, in game and in the previews alike. + */ +internal object ShaderNormalNodes { + val all: List by lazy { listOf(offset(), bump(), surfaceNormal(), lambert()) } + + /** How far to move a vertex along its normal: link it into the vertex offset of the output. */ + private fun offset() = shaderNode("hollowengine:normal/offset", NORMAL) { + group("displace") + preview(ShaderPreviewStyle.DISPLACEMENT) + val amount = input("Amount", 0.1f, type = FLOAT) + val normal = input("Normal", type = VEC3, fallback = ShaderInput.NORMAL) + output("Offset", VEC3) { "normalize(${normal.code}) * ${amount.code}" } + } + + /** + * The normal tilted by how [Height] changes across the surface: fine detail a mesh does not have, + * such as ripples, for a fresnel or light to pick up. Strength is how many blocks a height of one is. + */ + private fun bump() = shaderNode("hollowengine:normal/bump", NORMAL) { + group("normals") + preview(ShaderPreviewStyle.SIGNED) + uses(ShaderLibrary.NORMALS) + fragmentOnly() + val height = input("Height", 0f, type = FLOAT) + val strength = input("Strength", 0.1f, type = FLOAT) + val normal = input("Normal", type = VEC3, fallback = ShaderInput.NORMAL) + val position = input("Position", type = VEC3, fallback = ShaderInput.POSITION) + output("Normal", VEC3) { "sg_bump(normalize(${normal.code}), ${position.code}, ${height.code}, ${strength.code})" } + } + + /** + * The normal of the surface as it is drawn, worked out from the position: after a vertex offset + * has moved it, which the normal of the mesh knows nothing about. Faceted, one per triangle. + */ + private fun surfaceNormal() = shaderNode("hollowengine:normal/surface", NORMAL) { + group("normals") + preview(ShaderPreviewStyle.SIGNED) + uses(ShaderLibrary.NORMALS) + fragmentOnly() + val position = input("Position", type = VEC3, fallback = ShaderInput.POSITION) + val view = input("View", type = VEC3, fallback = ShaderInput.VIEW_DIRECTION) + output("Normal", VEC3) { "sg_surface_normal(${position.code}, ${view.code})" } + } + + /** + * How much light a surface facing [Normal] gets from [Direction], 0 to 1; Wrap lets it reach + * around the unlit side, as it does through something soft. + */ + private fun lambert() = shaderNode("hollowengine:normal/lambert", NORMAL) { + group("light") + val normal = input("Normal", type = VEC3, fallback = ShaderInput.NORMAL) + val direction = input("Direction", 0.4f, 1f, 0.3f, type = VEC3) + val wrap = input("Wrap", 0f, type = FLOAT) + output("Light", FLOAT) { + "clamp((dot(normalize(${normal.code}), normalize(${direction.code})) + ${wrap.code}) / (1.0 + ${wrap.code}), 0.0, 1.0)" + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderTypes.kt new file mode 100644 index 000000000..deea15294 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderTypes.kt @@ -0,0 +1,142 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import kotlinx.serialization.Serializable + +/** A value that flows along a link of a shader graph. */ +@Serializable +enum class ShaderType(val glsl: String, val width: Int) { + FLOAT("float", 1), VEC2("vec2", 2), VEC3("vec3", 3), VEC4("vec4", 4), + + /** A texture to sample; it only ever connects to another texture pin. */ + TEXTURE("sampler2D", 0); + + val isVector: Boolean get() = width > 0 + + companion object { + fun ofWidth(width: Int): ShaderType = when (width) { + 1 -> FLOAT + 2 -> VEC2 + 3 -> VEC3 + else -> VEC4 + } + } +} + +/** + * The type a pin is declared with: a fixed one; [DYNAMIC], where every dynamic pin of the node takes + * the widest vector any of them is given; or [ANY], which keeps whatever vector it is given as it is. + */ +enum class ShaderPinType(val fixed: ShaderType?) { + FLOAT(ShaderType.FLOAT), VEC2(ShaderType.VEC2), VEC3(ShaderType.VEC3), VEC4(ShaderType.VEC4), TEXTURE(ShaderType.TEXTURE), DYNAMIC( + null + ), + ANY(null); + + /** Whether a value of [type] can be linked into a pin of this type. */ + fun accepts(type: ShaderType): Boolean = when (fixed) { + null -> type.isVector + else -> coerce("", type, fixed) != null + } + + companion object { + fun of(type: ShaderType): ShaderPinType = entries.first { it.fixed == type } + } +} + +/** + * [expression] of type [from] read as [to]: a number spreads over every component, a vector is cut + * down or padded (with 0, and 1 for alpha). Null when there is no such reading, which is anything to + * or from a texture. + */ +fun coerce(expression: String, from: ShaderType, to: ShaderType): String? { + if (from == to) return expression + if (!from.isVector || !to.isVector) return null + return when { + from == ShaderType.FLOAT -> "${to.glsl}($expression)" + to == ShaderType.FLOAT -> "($expression).x" + to.width < from.width -> "($expression).${"xyzw".take(to.width)}" + to == ShaderType.VEC4 && from == ShaderType.VEC3 -> "vec4($expression, 1.0)" + to == ShaderType.VEC4 -> "vec4($expression, 0.0, 1.0)" + else -> "vec3($expression, 0.0)" + } +} + +/** A GLSL literal of [type] out of [values], missing components taken from the last one given. */ +fun shaderLiteral(type: ShaderType, values: List): String { + val components = (0 until type.width).map { values.getOrElse(it) { values.lastOrNull() ?: 0f } } + if (type == ShaderType.FLOAT) return glslFloat(components.first()) + if (components.distinct().size == 1) return "${type.glsl}(${glslFloat(components.first())})" + return "${type.glsl}(${components.joinToString(", ") { glslFloat(it) }})" +} + +/** A float as GLSL reads it: always with a point, never with a locale's comma. */ +fun glslFloat(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0.0" + val text = value.toString() + return if ('.' in text || 'E' in text || 'e' in text) text else "$text.0" +} + +/** + * What the engine hands a graph wherever it runs: each template declares the ones it can offer and + * defines them under these names before the graph's code. The functions among them read the scene + * where there is one and stand in for it where there is not, as in a node preview. + */ +enum class ShaderInput( + val glsl: String, + val type: ShaderType, + val fragmentOnly: Boolean = false, + val expressionName: String? = null, +) { + /** 0 to 1 across the surface, whatever part of the texture it shows. */ + UV("sg_uv", ShaderType.VEC2, expressionName = "uv"), + + /** The part of the material texture the surface shows, flipbook frame included. */ + TEXTURE_UV("sg_texture_uv", ShaderType.VEC2), + + /** + * A function from a UV across the surface to the texture UV that shows it, flipbook frame + * included; `sg_frame_uv(sg_uv)` is [TEXTURE_UV]. + */ + FRAME_UV("sg_frame_uv", ShaderType.VEC2), + + /** The particle color, tint and light already multiplied in. */ + COLOR("sg_color", ShaderType.VEC4, expressionName = "color"), + + /** Seconds; in game they run on as long as the game does, in the editor from when it opened. */ + TIME("sg_time", ShaderType.FLOAT, expressionName = "time"), + + /** Where the point is, relative to the camera, in blocks. */ + POSITION("sg_position", ShaderType.VEC3, expressionName = "position"), + + /** + * Where the point is on the object itself, -1 to 1 across it: a particle, a built-in mesh, the mesh + * of a preview. + */ + OBJECT_POSITION("sg_object_position", ShaderType.VEC3, expressionName = "local"), NORMAL( + "sg_normal", + ShaderType.VEC3, + expressionName = "normal" + ), + + /** From the point toward the eye, unit length. */ + VIEW_DIRECTION("sg_view_direction", ShaderType.VEC3, expressionName = "view"), + + /** Where on the screen the fragment is, 0 to 1. */ + SCREEN_UV("sg_screen_uv", ShaderType.VEC2, fragmentOnly = true), + + /** The texture the material names. */ + MAIN_TEXTURE("Sampler0", ShaderType.TEXTURE), + + /** Blocks from the eye to whatever the frame had drawn behind this fragment. */ + SCENE_DEPTH("sg_scene_depth()", ShaderType.FLOAT, fragmentOnly = true), + + /** Blocks from the eye to this fragment. */ + FRAGMENT_DEPTH("sg_fragment_depth()", ShaderType.FLOAT, fragmentOnly = true), + + /** A function of a screen UV: the frame as it was before the effect drew. */ + SCENE_COLOR("sg_scene_color", ShaderType.VEC3, fragmentOnly = true); + + companion object { + fun forExpression(name: String): ShaderInput? = entries.firstOrNull { it.expressionName == name } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/ClientSlots.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/ClientSlots.kt index 1d77ea93c..fa31ce940 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/ClientSlots.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/ClientSlots.kt @@ -19,9 +19,9 @@ import ru.hollowhorizon.hollowengine.common.slots.net.SlotIntentPacket * refuses and replies with the truth. Without this the cursor would visibly lag the mouse by a round * trip on any real connection. * - * When the client cannot fully evaluate a layout (a server-only filter, a handler that may cancel) it - * predicts nothing, and keeps one gesture in flight at a time: a second would quote a revision the server - * has already left behind. + * A gesture that reaches a zone the client cannot fully evaluate (a server-only filter, a handler that may + * cancel) is not predicted, and while it is in flight nothing else is sent: a second gesture would quote a + * revision the server may already have left behind. */ class ClientSlotSession internal constructor( val sessionId: Int, @@ -55,14 +55,14 @@ class ClientSlotSession internal constructor( * started rather than stacked on the previous step's result. */ fun beginPreview() { - if (layout.isPredictable) previewBase = state.copy() + previewBase = state.copy() } /** Shows what [intent] would do, from the state [beginPreview] captured. Sends nothing. */ fun preview(intent: SlotIntent) { val base = previewBase ?: return state.restoreFrom(base) - layout.applyClick(state, intent) + if (layout.predicts(intent)) layout.applyClick(state, intent) publish() } @@ -83,7 +83,8 @@ class ClientSlotSession internal constructor( state.restoreFrom(base) } - if (layout.isPredictable) { + if (awaitingAck) return + if (layout.predicts(intent)) { val result = layout.applyClick(state, intent) // A gesture that changes nothing locally would change nothing on the server either, so it is // not worth a packet, and sending it would desynchronize the revision. @@ -94,7 +95,6 @@ class ClientSlotSession internal constructor( return } - if (awaitingAck) return awaitingAck = true SlotIntentPacket(sessionId, revision, intent).send() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/SlotInteraction.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/SlotInteraction.kt index 45a67c87d..3dcbe0455 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/SlotInteraction.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/slots/SlotInteraction.kt @@ -1,6 +1,8 @@ package ru.hollowhorizon.hollowengine.client.slots import androidx.compose.runtime.mutableStateOf +import net.minecraft.Util +import net.minecraft.client.Minecraft import net.minecraft.world.item.ItemStack import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.common.slots.SlotButton @@ -24,8 +26,12 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio private var pendingButton: Int? = null private var pendingSlot = -1 + private var pendingCollect = false private val dragged = LinkedHashSet() + private var lastPressSlot = -1 + private var lastPressTime = 0L + /** Flat index the pointer is over, or -1. Drives the tooltip and the key gestures. */ val hovered: Int get() = hoveredState.value @@ -53,6 +59,15 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio internal fun press(slot: Int, button: Int, shift: Boolean) { cancelPending() + val now = Util.getMillis() + val doubleClick = button == LEFT_BUTTON && slot == lastPressSlot && now - lastPressTime < DOUBLE_CLICK_MILLIS + lastPressSlot = slot + lastPressTime = now + + if (button == MIDDLE_BUTTON) { + if (Minecraft.getInstance().player?.abilities?.instabuild == true) session.submit(SlotIntent.clone(slot)) + return + } if (button != LEFT_BUTTON && button != RIGHT_BUTTON) return if (shift) { @@ -68,6 +83,7 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio pendingButton = button pendingSlot = slot + pendingCollect = doubleClick dragged += slot session.beginPreview() } @@ -85,12 +101,16 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio internal fun release() { val button = pendingButton ?: return val slot = pendingSlot + val collect = pendingCollect // Read the targets out before clearing the gesture: they are what gets submitted. val targets = dragged.toList() cancelPending() - if (targets.size >= 2) session.submit(distributeIntent(button, targets)) - else if (slot >= 0) session.submit(SlotIntent.click(slot, button.asSlotButton())) + when { + targets.size >= 2 -> session.submit(distributeIntent(button, targets)) + collect && slot >= 0 -> session.submit(SlotIntent.collect(slot)) + slot >= 0 -> session.submit(SlotIntent.click(slot, button.asSlotButton())) + } } /** Q on the hovered slot, or on the cursor stack when the pointer is over nothing. */ @@ -102,7 +122,7 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio /** A press outside the container window puts the cursor stack on the ground, as in vanilla. */ internal fun dropOutside(button: Int) { - if (session.carried.isEmpty) return + if (session.carried.isEmpty || (button != LEFT_BUTTON && button != RIGHT_BUTTON)) return session.submit(SlotIntent.dropCarried(all = button == LEFT_BUTTON)) } @@ -115,6 +135,7 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio if (pendingButton != null) session.cancelPreview() pendingButton = null pendingSlot = -1 + pendingCollect = false dragged.clear() } @@ -126,5 +147,9 @@ class SlotInteraction internal constructor(private val session: ClientSlotSessio private companion object { const val LEFT_BUTTON = 0 const val RIGHT_BUTTON = 1 + const val MIDDLE_BUTTON = 2 + + /** Vanilla's window for a double click in a container screen. */ + const val DOUBLE_CLICK_MILLIS = 250L } } 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 381718875..35ee1a5f8 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 @@ -16,9 +16,6 @@ class HollowUiInputController { private var dragMoved = false - // Focus is per scope (root, each popup, each dock window): every scope independently owns one - // focused `focusable` target, so a popup and a text field stay focused at the same time. The - // active scope is the one that last gained focus - Tab cycles within it. private val focusByScope = LinkedHashMap() private val hoverChain = HashSet() private val runtimeStack = ArrayDeque() @@ -37,6 +34,21 @@ class HollowUiInputController { private var scrollbarDrag: UiScrollbarDragState? = null + /** The scrollbar under the pointer or in the hand, drawn in its hover style; see [ScrollbarNode.engaged]. */ + private var engagedScrollbar: ScrollbarNode? = null + + private fun updateEngagedScrollbar() { + val next = scrollbarDrag?.scrollbar ?: when (val node = hoveredNode) { + is ScrollbarThumbNode -> node.layoutState.parentNode as? ScrollbarNode + is ScrollbarNode -> node + else -> null + } + if (next === engagedScrollbar) return + engagedScrollbar?.engaged = false + next?.engaged = true + engagedScrollbar = next + } + fun reset() { clearInteraction() } @@ -46,26 +58,19 @@ class HollowUiInputController { activeNode = null draggingNode = null scrollbarDrag = null + updateEngagedScrollbar() if (clearFocus) { focusByScope.clear() activeScope = null } } - fun isHovered(id: String): Boolean = hoveredNode?.id == id - fun prepareRoot(root: UiNode, closing: Boolean = false) { remapTrackedNodes(root) applyRuntimeStates(root, closing) } - /** - * Re-binds interaction tracking (hover/active/focus/drag) to the current tree by id. - * Compose may replace a node instance across a recomposition while keeping its id; without - * this, the stale instance would fail identity checks and the node would lose its hover/ - * active/focus state for a frame — visibly resetting transitions (e.g. a hover animation - * snapping back on click) and dropping press→release on re-parented nodes. - */ + /** Re-binds interaction tracking (hover/active/focus/drag) to the current tree by id.*/ private fun remapTrackedNodes(root: UiNode) { val hovered = hoveredNode val active = activeNode @@ -110,8 +115,7 @@ class HollowUiInputController { draggingNode = remap(dragging) activeScope = remap(scope)?.takeIf { inTree(it) } if (focusByScope.isNotEmpty()) { - fun relocate(node: UiNode): UiNode? = - node.id?.let { firstById[it] } ?: node.takeIf { it in present } + fun relocate(node: UiNode): UiNode? = node.id?.let { firstById[it] } ?: node.takeIf { it in present } val remappedFocus = focusByScope.entries.mapNotNull { (focusScope, target) -> val s = relocate(focusScope) ?: return@mapNotNull null @@ -132,18 +136,16 @@ class HollowUiInputController { val hit = frame.hitTest(mouseX, mouseY) val previousNode = hoveredNode hoveredNode = hit?.node + updateEngagedScrollbar() x = mouseX y = mouseY if (previousNode === hoveredNode) return false - previousNode - ?.takeIf { it in frame.nodes } + previousNode?.takeIf { it in frame.nodes } ?.let { dispatch(frame.pointerEvent(UiEventKind.EXIT, it, mouseX, mouseY)) } - hoveredNode - ?.takeIf { it in frame.nodes } - ?.let { + hoveredNode?.takeIf { it in frame.nodes }?.let { dispatch(frame.pointerEvent(UiEventKind.ENTER, it, mouseX, mouseY)) } return true @@ -210,6 +212,7 @@ class HollowUiInputController { localY = hit.localY, width = layoutNode.rect.width, height = layoutNode.rect.height, + ancestorLocalPositions = frame.ancestorLocalPositions(hit.node, mouseX, mouseY), ) val pressHandled = dispatch(press) @@ -246,6 +249,7 @@ class HollowUiInputController { else -> null } ?: return UiInputResult(false) scrollbarDrag = drag + updateEngagedScrollbar() return UiInputResult(true, drag.node, drag.node.id, changed = true) } @@ -307,26 +311,26 @@ class HollowUiInputController { ): UiInputResult { var received = false fun dispatch(node: UiNode): Boolean { - val event = - UiEvent( - kind = UiEventKind.RELEASE, - node = node, - frame = frame, - button = button, - modifiers = modifiers, - x = mouseX, - y = mouseY, - released = true - ) + val event = UiEvent( + kind = UiEventKind.RELEASE, + node = node, + frame = frame, + button = button, + modifiers = modifiers, + x = mouseX, + y = mouseY, + released = true + ) received = dispatch(event) || received return event.consumed } - val handled = (frame.hitTest(mouseX, mouseY)?.node - ?: activeNode?.takeIf { it in frame.nodes })?.let { dispatch(it) } ?: false + val handled = + (frame.hitTest(mouseX, mouseY)?.node ?: activeNode?.takeIf { it in frame.nodes })?.let { dispatch(it) } + ?: false val releaseNode = draggingNode?.takeIf { it in frame.nodes } - if(!handled) releaseNode?.let { node -> dispatch(node) } + if (!handled) releaseNode?.let { node -> dispatch(node) } if (releaseNode != null && !dragMoved) { val hit = frame.hitTest(mouseX, mouseY)?.takeIf { it.node == releaseNode } dispatchClick( @@ -345,6 +349,7 @@ class HollowUiInputController { draggingNode = null dragMoved = false scrollbarDrag = null + updateEngagedScrollbar() return UiInputResult(received, releaseNode, releaseNode?.id) } @@ -360,8 +365,6 @@ class HollowUiInputController { return UiInputResult(true, node, node.id, changed = true) } - fun hasScrollbarDrag(): Boolean = scrollbarDrag != null - fun charTyped( frame: HollowUiFrame, codePoint: Char, @@ -381,11 +384,11 @@ class HollowUiInputController { /** * The nodes that currently receive key/char events, most-in-front first. Multi-focus: every open - * focus scope (always active — the root, popups, dock windows) plus each scope's focused target. - * Higher layer (popups/overlays) gets first refusal. + * focus scope plus each scope's focused target. */ private fun focusTargets(frame: HollowUiFrame): List { val set = LinkedHashSet() + 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 } val ordered = set.sortedByDescending { it.resolvedSnapshot.layer } @@ -540,15 +543,15 @@ class HollowUiInputController { private fun isAltPressed(): Boolean { val window = Minecraft.getInstance()?.window?.window ?: return false - return GLFW.glfwGetKey(window, GLFW.GLFW_KEY_LEFT_ALT) == GLFW.GLFW_PRESS || - GLFW.glfwGetKey(window, GLFW.GLFW_KEY_RIGHT_ALT) == GLFW.GLFW_PRESS + return GLFW.glfwGetKey(window, GLFW.GLFW_KEY_LEFT_ALT) == GLFW.GLFW_PRESS || GLFW.glfwGetKey( + window, + GLFW.GLFW_KEY_RIGHT_ALT + ) == GLFW.GLFW_PRESS } internal fun focusedScrollableNode(frame: HollowUiFrame): UiNode? { return focusByScope.values.firstOrNull { - it in frame.layout.nodes && - it.resolvedSnapshot.scrollable && - frame.layout[it].scrollRange.hasScrollableAxis() + it in frame.layout.nodes && it.resolvedSnapshot.scrollable && frame.layout[it].scrollRange.hasScrollableAxis() } } 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 9fa7321b6..bab36cc72 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 @@ -16,6 +16,7 @@ import java.util.* private const val ScrollTargetRangeEpsilon = 0.5f private const val WheelNotchPixels = 32f +private const val PendingFocusFrames = 10 data class HollowUiFrame( val root: UiNode, @@ -186,6 +187,10 @@ class HollowUiRuntime( var lastFrame: HollowUiFrame? = null private set + @Volatile + private var pendingFocus: String? = null + private var pendingFocusFrames = 0 + val mouseX: Float get() = input.x val mouseY: Float get() = input.y val focusedKey get() = input.focusedKey @@ -231,6 +236,7 @@ class HollowUiRuntime( input.dispatchHover(frame, mouseX, mouseY, ::dispatchUiEvent) if (profile != null) profile.inputNanos += System.nanoTime() - inputStartedAt lastFrame = frame + applyPendingFocus(frame) return frame } @@ -380,11 +386,15 @@ class HollowUiRuntime( horizontalModifier: Boolean, ): UiEvent { val delta = scrollWheelDelta(frame.layout[node].scrollRange, input.scrollX, input.scrollY, horizontalModifier) + val local = frame.layout[node].inputTransform.inverse()?.transform(input.mouseX, input.mouseY, 0f) return UiEvent( kind = UiEventKind.SCROLL, node = node, + frame = frame, x = input.mouseX, y = input.mouseY, + localX = local?.x ?: 0f, + localY = local?.y ?: 0f, scrollX = delta.x, scrollY = delta.y, rawScrollX = input.scrollX, @@ -411,6 +421,9 @@ class HollowUiRuntime( val handle = node.scrollHandle() ?: continue handle.viewport = layoutNode.content handle.range = layoutNode.scrollRange + handle.screenTransform = layoutNode.inputTransform + handle.contentOffsetX = layoutNode.content.x - layoutNode.rect.x + handle.contentOffsetY = layoutNode.content.y - layoutNode.rect.y } } @@ -541,6 +554,15 @@ class HollowUiRuntime( result.consumed || result.changed } + /** + * Offers a key to the UI and reports only whether a handler consumed it. For keys the host also has + * a use for, such as Escape closing the screen, where "the UI changed something" is not a claim. + */ + fun keyConsumed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean = profileInput { + val frame = lastFrame ?: return@profileInput false + processInput(frame, QueuedUiInput.KeyPressed(keyCode, scanCode, modifiers, false)).consumed + } + private inline fun profileInput(block: () -> Boolean): Boolean { if (!profiler.enabled) return block() val startedAt = System.nanoTime() @@ -557,6 +579,27 @@ class HollowUiRuntime( lastLayoutKey = null } + /** + * Focuses the node with [id] on the first frame that has it, or never if none does within a few + * frames; for content that is only now being composed, such as a field swapped in for a label. + */ + fun requestFocus(id: String) { + pendingFocusFrames = 0 + pendingFocus = id + } + + private fun applyPendingFocus(frame: HollowUiFrame) { + val id = pendingFocus ?: return + when { + frame.nodeByIdentifier(id) != null -> { + pendingFocus = null + input.focus(frame, id, ::dispatchUiEvent) + } + + ++pendingFocusFrames > PendingFocusFrames -> pendingFocus = null + } + } + fun focus(editorKey: String) { input.focus(lastFrame ?: return, editorKey, ::dispatchUiEvent) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiSurface.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiSurface.kt index c25a0be11..d1e45c374 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiSurface.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/HollowUiSurface.kt @@ -44,6 +44,7 @@ class HollowUiSurface( LocalPointer provides pointer, LocalOverlayManager provides overlayManager, LocalUiViewport provides viewport, + LocalUiFocusRequester provides UiFocusRequester(runtime::requestFocus), ) { content() OverlayHost() 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 56f45350c..6498ab656 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 @@ -18,6 +18,7 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiTransition import ru.hollowhorizon.hollowengine.client.ui.style.UiCaretBlinkKeyframes import ru.hollowhorizon.hollowengine.client.ui.style.UiCaretBlinkPeriodMillis import ru.hollowhorizon.hollowengine.client.ui.style.UiStylesheetReference +import ru.hollowhorizon.hollowengine.client.ui.style.fontFamily import ru.hollowhorizon.hollowengine.client.ui.text.Shadow import ru.hollowhorizon.hollowengine.client.ui.widgets.* import kotlin.coroutines.CoroutineContext @@ -29,6 +30,13 @@ val LocalUiFrameTimeNanos = staticCompositionLocalOf { 0L } val LocalUiViewport = compositionLocalOf { UiRect.Zero } val LocalStylesheets = staticCompositionLocalOf> { emptyList() } +/** Focuses a node by its id once it is on screen; see [HollowUiRuntime.requestFocus]. */ +fun interface UiFocusRequester { + fun request(id: String) +} + +val LocalUiFocusRequester = staticCompositionLocalOf { UiFocusRequester {} } + private fun Modifier?.styleReferences(): List = when (this) { null -> emptyList() is CompositeModifier -> flatten().filterIsInstance().map { it.reference } @@ -151,17 +159,19 @@ fun Layout( } } } - ReusableComposeNode( - factory = { BoxNode(id, measurePolicy, tags, modifiers, attributes) }, - update = { - update(measurePolicy) { - this.measurePolicy = it - invalidateLayout() - } - updateCommon(modifiers, attributes, tags) - }, - content = scoped, - ) + key(id) { + ReusableComposeNode( + factory = { BoxNode(id, measurePolicy, tags, modifiers, attributes) }, + update = { + update(measurePolicy) { + this.measurePolicy = it + invalidateLayout() + } + updateCommon(modifiers, attributes, tags) + }, + content = scoped, + ) + } } @@ -201,16 +211,18 @@ fun Span( modifier: Modifier? = null, ) { val modifiers = modifier.asList() - ReusableComposeNode( - factory = { SpanNode(value, id, tags, modifiers) }, - update = { - update(value) { - text = it - invalidateLayout() - } - updateCommon(modifiers, emptyMap(), tags) - }, - ) + key(id) { + ReusableComposeNode( + factory = { SpanNode(value, id, tags, modifiers) }, + update = { + update(value) { + text = it + invalidateLayout() + } + updateCommon(modifiers, emptyMap(), tags) + }, + ) + } } @Composable @@ -292,19 +304,22 @@ fun Element( ) { val modifiers = modifier.asList() val nodeType = type.lowercase() - ReusableComposeNode( - factory = { - BaseUiNode( - nodeType, - id?.removePrefix("#"), - tags.map { it.removePrefix(".") }, - modifiers, - attributes, - ) - }, - update = { updateCommon(modifiers, attributes, tags.map { it.removePrefix(".") }) }, - content = content, - ) + // A node is its id: interaction state is tracked by it, so another id is another node. + key(id) { + ReusableComposeNode( + factory = { + BaseUiNode( + nodeType, + id?.removePrefix("#"), + tags.map { it.removePrefix(".") }, + modifiers, + attributes, + ) + }, + update = { updateCommon(modifiers, attributes, tags.map { it.removePrefix(".") }) }, + content = content, + ) + } } @@ -549,7 +564,8 @@ fun TextField( fieldState.autoPairs = autoPairs fieldState.multiCaret = multiCaret fieldState.fontSize = fontSize - fieldState.fontFamily = fontFamily + var hssFontFamily by remember { mutableStateOf(null) } + fieldState.fontFamily = fontFamily ?: hssFontFamily fieldState.wrap = wrap ?: multiline var hssShadow by remember { mutableStateOf(HssShadowResolution.Inherit) } var hssInlayHints by remember { mutableStateOf(true) } @@ -588,6 +604,7 @@ fun TextField( HssShadowResolution.Inherit } if (next != hssShadow) hssShadow = next + if (style.fontFamily != hssFontFamily) hssFontFamily = style.fontFamily val hintsEnabled = textFieldStyle.inlayHints != false if (hintsEnabled != hssInlayHints) hssInlayHints = hintsEnabled }, @@ -670,12 +687,14 @@ private fun ContentNode( attributes: Map, ) { val modifiers = listOf(if (modifier == null) contentModifier else contentModifier then modifier) - ReusableComposeNode( - factory = { - BaseUiNode(type, id?.removePrefix("#"), tags.map { it.removePrefix(".") }, modifiers, attributes) - }, - update = { updateCommon(modifiers, attributes, tags) }, - ) + key(id) { + ReusableComposeNode( + factory = { + BaseUiNode(type, id?.removePrefix("#"), tags.map { it.removePrefix(".") }, modifiers, attributes) + }, + update = { updateCommon(modifiers, attributes, tags) }, + ) + } } /** Popups render above everything; the OverlayHost carries this layer so all passes prefer it. */ @@ -810,17 +829,19 @@ private fun PopupNodeEmitter( val styledContent: HollowUiContent = { CompositionLocalProvider(LocalStylesheets provides stylesheets) { content() } } - ReusableComposeNode( - factory = { PopupNode(anchorBounds, alignment, id, tags, modifiers, attributes) }, - update = { - update(values) { - apply(it) - invalidateLayout() - } - updateCommon(modifiers, attributes, tags) - }, - content = styledContent, - ) + key(id) { + ReusableComposeNode( + factory = { PopupNode(anchorBounds, alignment, id, tags, modifiers, attributes) }, + update = { + update(values) { + apply(it) + invalidateLayout() + } + updateCommon(modifiers, attributes, tags) + }, + content = styledContent, + ) + } } /** diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCursorManager.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCursorManager.kt index bcafb7ec9..229ab4221 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCursorManager.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiCursorManager.kt @@ -68,5 +68,6 @@ object UiCursorManager { UiCursorShape.RESIZE_VERTICAL -> GLFW_RESIZE_NS_CURSOR UiCursorShape.RESIZE_NESW -> GLFW_RESIZE_NESW_CURSOR UiCursorShape.RESIZE_NWSE -> GLFW_RESIZE_NWSE_CURSOR + UiCursorShape.NOT_ALLOWED -> GLFW_NOT_ALLOWED_CURSOR } } 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 64e471016..2c9caf2b6 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 @@ -463,7 +463,7 @@ class UiCommandRenderer { if (pushedClip) { commands += PushClipCommand( node, - layoutNode.content.localTo(layoutNode.rect), + layoutNode.overflowClip.localTo(layoutNode.rect), layoutNode.worldTransform, ) } @@ -479,8 +479,8 @@ class UiCommandRenderer { val childClip = when { !pushedClip -> activeClip - activeClip == null -> layoutNode.content.takeIf { it.hasVisibleArea() } - else -> activeClip.visibleIntersection(layoutNode.content) + activeClip == null || !layoutBoundsMatchVisualBounds -> layoutNode.overflowClip.takeIf { it.hasVisibleArea() } + else -> activeClip.visibleIntersection(layoutNode.overflowClip) } if (!pushedClip || childClip != null) { val sorted = layout.childrenOf(node) 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 new file mode 100644 index 000000000..eb16f2a21 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/UiSpring.kt @@ -0,0 +1,72 @@ +package ru.hollowhorizon.hollowengine.client.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlin.math.abs + +/** + * A number that springs towards its target instead of jumping there. Whoever owns it calls + * [advance] from a `withFrameNanos` loop. + * + * [rest] is how close counts as arrived; it has to be small next to the steps the value takes, or + * the last stretch of the motion is a visible jump. + */ +internal class SpringFloat(initial: Float, private val rest: Float = 0.05f) { + var value by mutableStateOf(initial) + private set + + var target: Float = initial + + private var velocity = 0f + private var lastFrame = 0L + + /** Puts the value there at once, for changes the pointer is already animating by hand. */ + fun snapTo(next: Float) { + target = next + value = next + velocity = 0f + } + + fun advance(frameNanos: Long) { + val previous = lastFrame + lastFrame = frameNanos + if (previous == 0L) return + + val delta = target - value + if (abs(delta) < rest && abs(velocity) < rest) { + if (value != target) value = target + velocity = 0f + return + } + + val dt = ((frameNanos - previous) / 1_000_000_000f).coerceIn(0f, 0.05f) + velocity += (delta * 300f - velocity * 32f) * dt + value += velocity * dt + } +} + +/** + * The zoom of an orbit preview, kept as a camera distance or as a model scale. + */ +internal class SpringZoom( + initial: Float, + private val min: Float, + private val max: Float, + private val perNotch: Float, +) { + private val spring = SpringFloat(initial, rest = 0.0005f) + + val value: Float get() = spring.value + + fun scroll(scrollY: Float) { + if (scrollY == 0f) return + val factor = if (scrollY > 0f) perNotch else 1f / perNotch + spring.target = (spring.target * factor).coerceIn(min, max) + } + + fun advance(frameNanos: Long) = spring.advance(frameNanos) +} + +/** How much one wheel notch scales a model preview. */ +internal const val ModelZoomPerNotch = 1f / 1.2f 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 3def79ffd..b789e1d73 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 @@ -268,7 +268,8 @@ enum class UiCursorShape { RESIZE_HORIZONTAL, RESIZE_VERTICAL, RESIZE_NESW, - RESIZE_NWSE + RESIZE_NWSE, + NOT_ALLOWED } sealed interface UiLength { @@ -414,6 +415,7 @@ data class UiBorder( val width: UiInsets = UiInsets.Zero, val paint: UiPaint = UiPaint.None, val radius: Float = 0f, + val dash: UiBorderDash? = null, ) { constructor(width: UiInsets, color: UiColor, radius: Float = 0f) : this(width, UiPaint.Color(color), radius) @@ -421,6 +423,13 @@ data class UiBorder( val color: UiColor? get() = (paint as? UiPaint.Color)?.color } +/** + * Dashes along a border, measured along its center line. + */ +data class UiBorderDash(val length: Float, val gap: Float) { + val isSolid: Boolean get() = length <= 0f || gap <= 0f +} + data class UiTransform( val translate: UiVec3 = UiVec3(), val rotate: UiVec3 = UiVec3(), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockGeometry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockGeometry.kt index b9e665301..15c0f0eb1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockGeometry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockGeometry.kt @@ -74,26 +74,15 @@ object DockLayoutCalculator { } } -object DockDropResolver { - private const val EdgeRatio = 0.12f - - fun resolve(layouts: List, x: Float, y: Float): DockTarget? { - val stack = layouts.asReversed().firstOrNull { it.stack && it.rect.contains(x, y) } - if (stack != null) return DockTarget(stack.nodeId, placementFor(stack.rect, x, y)) - - val node = layouts.asReversed().firstOrNull { it.rect.contains(x, y) } ?: return null - return DockTarget(node.nodeId, placementFor(node.rect, x, y)) - } - - private fun placementFor(rect: DockRect, x: Float, y: Float): DockPlacement { - val localX = ((x - rect.x) / rect.width.coerceAtLeast(1f)).coerceIn(0f, 1f) - val localY = ((y - rect.y) / rect.height.coerceAtLeast(1f)).coerceIn(0f, 1f) - return when { - localX < EdgeRatio -> DockPlacement.LEFT - localX > 1f - EdgeRatio -> DockPlacement.RIGHT - localY < EdgeRatio -> DockPlacement.TOP - localY > 1f - EdgeRatio -> DockPlacement.BOTTOM - else -> DockPlacement.CENTER - } - } +fun dockPreviewRect(target: DockTarget, layouts: List, bounds: DockRect): DockRect? { + val base = if (target.anchorId == null) bounds else layouts.firstOrNull { it.nodeId == target.anchorId }?.rect + return base?.part(target.placement) } + +private fun DockRect.part(placement: DockPlacement): DockRect = when (placement) { + DockPlacement.CENTER -> this + DockPlacement.LEFT -> copy(width = width * 0.5f) + DockPlacement.RIGHT -> copy(x = x + width * 0.5f, width = width * 0.5f) + DockPlacement.TOP -> copy(height = height * 0.5f) + DockPlacement.BOTTOM -> copy(y = y + height * 0.5f, height = height * 0.5f) +} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockModel.kt index 68546a943..4c09b981e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockModel.kt @@ -1,5 +1,8 @@ package ru.hollowhorizon.hollowengine.client.ui.docking +import androidx.compose.runtime.compositionLocalOf +import ru.hollowhorizon.hollowengine.client.ui.Modifier + data class DockItem( val id: String, val title: String, @@ -8,8 +11,8 @@ data class DockItem( val minWidth: Float = 48f, val minHeight: Float = 32f, val dirty: Boolean = false, - val singleTabPresentation: DockSingleTabPresentation = DockSingleTabPresentation.BAR, val pinnable: Boolean = false, + val titleInToolbar: Boolean = false, ) /** Which edge of the dock space a pinned item's stripe button sits on. */ @@ -22,19 +25,40 @@ enum class DockSide { val opposite: DockSide get() = if (this == LEFT) RIGHT else LEFT } +/** + * Which part of a stripe a tool window sits in. + * + * [TOP] opens beside the editor, above the bottom area. [SPLIT] opens in the same side panel, under + * the [TOP] window, so the two show at once with a splitter between them. [BOTTOM] opens into the + * area under the editor and the side panels, the full width between the stripes. + */ +enum class DockStripeGroup { + TOP, + SPLIT, + BOTTOM, +} + +/** Where on the stripes a tool window is parked; one window per anchor is open at a time. */ +data class DockAnchor(val side: DockSide, val group: DockStripeGroup = DockStripeGroup.TOP) { + companion object { + val all: List = DockSide.entries.flatMap { side -> + DockStripeGroup.entries.map { group -> DockAnchor(side, group) } + } + } +} + data class DockPinnedItem( val item: DockItem, val side: DockSide, val width: Float = DefaultPinnedWidth, -) + val group: DockStripeGroup = DockStripeGroup.TOP, +) { + val anchor: DockAnchor get() = DockAnchor(side, group) +} /** Width a tool window's panel takes the first time it is pinned. */ const val DefaultPinnedWidth = 260f - -enum class DockSingleTabPresentation { - BAR, - OVERLAY, -} +const val DefaultBottomHeight = 220f sealed interface DockNode { val id: String @@ -75,6 +99,13 @@ data class DockTabDragState( val layouts: List = emptyList(), ) +/** A stripe button in the hand: the pointer's place along its stripe, and how far down the button it took hold. */ +data class DockStripeDrag( + val itemId: String, + val along: Float, + val grab: Float, +) + data class DockTabGrabState( val stackId: String, val itemId: String, @@ -306,3 +337,16 @@ internal class DockIdGenerator { fun nextWindowId(): String = "dock-window-${nextWindow++}" } + +/** + * The title a parked window is expected to show itself, when it asked for [DockItem.titleInToolbar]. + */ +class DockPanelTitle( + val title: String, + val icon: String?, + val headerId: String, + val dragHandle: Modifier, +) + +/** Set for the content of a parked window that draws its own title; null everywhere else. */ +val LocalDockPanelTitle = compositionLocalOf { null } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockSideStripe.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockSideStripe.kt index fad733c74..c5f4eaf6c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockSideStripe.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockSideStripe.kt @@ -1,89 +1,246 @@ package ru.hollowhorizon.hollowengine.client.ui.docking -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import kotlinx.coroutines.delay -import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.Box +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.Image +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Row +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.UiAlign +import ru.hollowhorizon.hollowengine.client.ui.UiCursorShape +import ru.hollowhorizon.hollowengine.client.ui.UiPopupAlignment +import ru.hollowhorizon.hollowengine.client.ui.align +import ru.hollowhorizon.hollowengine.client.ui.alignItems +import ru.hollowhorizon.hollowengine.client.ui.clip +import ru.hollowhorizon.hollowengine.client.ui.cursor +import ru.hollowhorizon.hollowengine.client.ui.grow +import ru.hollowhorizon.hollowengine.client.ui.input +import ru.hollowhorizon.hollowengine.client.ui.isRightClick +import ru.hollowhorizon.hollowengine.client.ui.layer import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.onDrag +import ru.hollowhorizon.hollowengine.client.ui.onPlaced +import ru.hollowhorizon.hollowengine.client.ui.onPress +import ru.hollowhorizon.hollowengine.client.ui.onRelease +import ru.hollowhorizon.hollowengine.client.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.textWrap +import ru.hollowhorizon.hollowengine.client.ui.transition +import ru.hollowhorizon.hollowengine.client.ui.translate 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 ru.hollowhorizon.hollowengine.client.utils.lang +import kotlin.math.abs +import kotlin.math.hypot import kotlin.time.Duration.Companion.milliseconds /** Edge of a stripe button. */ private const val StripeButtonSize = 22f +/** A button and the gap after it, as `.dock-stripe` lays them out. */ +private const val StripeButtonPitch = 24f + +/** The line between the top and the split buttons: 1px, its margins in `.dock-stripe-divider`, one gap. */ +private const val StripeDividerWidth = 12f +private const val StripeDividerPitch = 9f + /** How long a panel takes to push the editor aside, and to give the room back. */ -private const val PanelSlideMillis = 160L +private const val PanelSlideMillis = 120L + +/** Width of the handles that drag a panel's inner edge. */ +internal const val PanelSplitterWidth = 3f + +/** How far a stripe button has to move before a press becomes a drag rather than a click. */ +private const val DragThreshold = 4f -/** Width of the handle that drags a pinned panel's inner edge. */ -private const val PanelSplitterWidth = 3f +/** How far past its stripe a button has to be dragged to come off it as a floating window. */ +private const val UndockDistance = 28f + +/** Where on a window just pulled off a stripe the pointer holds it: its header, near the left. */ +private const val HeaderGrabX = 48f +private const val HeaderGrabY = 12f + +/** How far a panel's header has to be dragged before the panel comes off: further than a click wobbles. */ +private const val HeaderUndockDistance = 8f internal fun DockingState.edgeWidth(side: DockSide): Float { val stripe = if (stripesVisible && pinnedOn(side).isNotEmpty()) StripeButtonSize else 0f - val panel = expandedOn(side)?.let { it.width + PanelSplitterWidth } ?: 0f + val panel = sideWidth(side)?.let { it + PanelSplitterWidth } ?: 0f return stripe + panel } -/** - * One edge of the dock space: the stripe of buttons for the tool windows parked on [side], and the - * panel the open one expands into. - */ -@Composable -internal fun DockSideEdge( - side: DockSide, - state: DockingState, - tabBarActions: DockTabBarActions, - content: DockItemContent, -) { - if (side == DockSide.LEFT) { - DockStripe(side, state) - DockPinnedPanel(side, state, tabBarActions, content) - } else { - DockPinnedPanel(side, state, tabBarActions, content) - DockStripe(side, state) - } -} +internal fun stripeId(side: DockSide): String = "dock-stripe-${side.tag}" +internal fun stripeButtonId(itemId: String): String = "dock-stripe-button-$itemId" + +/** The top buttons, a divider and the split ones under it, a gap, then the bottom buttons. */ @Composable -private fun DockStripe(side: DockSide, state: DockingState) { - val items = state.pinnedOn(side) - val open = state.stripesVisible && items.isNotEmpty() +internal fun DockStripe(side: DockSide, state: DockingState) { + val open = state.stripesVisible && state.pinnedOn(side).isNotEmpty() + var height by remember { mutableStateOf(0f) } Column( - id = "dock-stripe-${side.tag}", + id = stripeId(side), tags = listOf(DockTags.Stripe, side.tag), modifier = Modifier.size(if (open) StripeButtonSize.px else 0.px, 100.percent) - .alignItems(horizontal = UiAlign.CENTER).clip(), + .alignItems(horizontal = UiAlign.CENTER).clip().onPlaced { height = it.height }, ) { - items.forEach { pinned -> - key(pinned.item.id) { DockStripeButton(pinned, state) } + state.pinnedIn(DockAnchor(side, DockStripeGroup.TOP)).forEach { pinned -> + key(pinned.item.id) { DockStripeButton(pinned, state) { height } } + } + val split = state.pinnedIn(DockAnchor(side, DockStripeGroup.SPLIT)) + if (split.isNotEmpty()) { + Box( + tags = listOf(DockTags.StripeDivider), + modifier = Modifier.size(StripeDividerWidth.px, 1.px), + ) + split.forEach { pinned -> + key(pinned.item.id) { DockStripeButton(pinned, state) { height } } + } + } + Box(modifier = Modifier.size(1.px, 0.px).grow(1f)) + state.pinnedIn(DockAnchor(side, DockStripeGroup.BOTTOM)).forEach { pinned -> + key(pinned.item.id) { DockStripeButton(pinned, state) { height } } } } } +internal fun pinnedHeaderId(itemId: String): String = "dock-pinned-header-$itemId" + +/** + * Lets a parked window's header pull it off into a floating window, the way its stripe button can. + */ +internal fun Modifier.pinnedHeaderDrag(itemId: String, state: DockingState): Modifier = + input(hoverable = true, clickable = true, draggable = true) + .onDrag { event -> + if (hypot(event.dragTotalX, event.dragTotalY) < HeaderUndockDistance) return@onDrag + event.consume() + val spaceX = event.localXInAncestor(DockTags.Space) ?: event.rootLocalX + val spaceY = event.localYInAncestor(DockTags.Space) ?: event.rootLocalY + state.undockPinned(itemId, spaceX - event.localX, spaceY - event.localY, pinnedHeaderId(itemId)) + } + +/** Where the slot of [itemId] in [anchor] starts, down its stripe, as `.dock-stripe` lays it out. */ +private fun DockingState.stripeSlotTop(anchor: DockAnchor, itemId: String, height: Float): Float { + val group = pinnedIn(anchor) + val index = group.indexOfFirst { it.item.id == itemId }.coerceAtLeast(0) + return when (anchor.group) { + DockStripeGroup.TOP -> index * StripeButtonPitch + DockStripeGroup.SPLIT -> + pinnedIn(DockAnchor(anchor.side, DockStripeGroup.TOP)).size * StripeButtonPitch + StripeDividerPitch + + index * StripeButtonPitch + DockStripeGroup.BOTTOM -> height - StripeButtonSize - (group.size - 1 - index) * StripeButtonPitch + } +} + +private fun DockingState.stripeSlotAt(side: DockSide, itemId: String, along: Float, height: Float): Pair { + fun othersIn(group: DockStripeGroup) = pinnedIn(DockAnchor(side, group)).count { it.item.id != itemId } + if (along >= height / 2f) { + val others = othersIn(DockStripeGroup.BOTTOM) + return DockAnchor(side, DockStripeGroup.BOTTOM) to others - ((height - along) / StripeButtonPitch).toInt() + } + val topCount = othersIn(DockStripeGroup.TOP) + if (along < topCount * StripeButtonPitch + StripeButtonSize) { + return DockAnchor(side, DockStripeGroup.TOP) to (along / StripeButtonPitch).toInt().coerceIn(0, topCount) + } + val splitStart = topCount * StripeButtonPitch + StripeDividerPitch + val index = ((along - splitStart) / StripeButtonPitch).toInt().coerceIn(0, othersIn(DockStripeGroup.SPLIT)) + return DockAnchor(side, DockStripeGroup.SPLIT) to index +} + +/** + * A stripe button. A click opens or closes its window. + */ @Composable -private fun DockStripeButton(pinned: DockPinnedItem, state: DockingState) { +private fun DockStripeButton(pinned: DockPinnedItem, state: DockingState, stripeHeight: () -> Float) { val item = pinned.item - val expanded = state.expandedOn(pinned.side)?.item?.id == item.id + val expanded = state.expandedIn(pinned.anchor)?.item?.id == item.id var anchor by remember { mutableStateOf(UiRect.Zero) } var menu by remember { mutableStateOf(false) } + val drag = state.stripeDrag?.takeIf { it.itemId == item.id } + val swap = if (drag == null) state.stripeSwapOffsets[item.id] else null + if (swap != null) { + LaunchedEffect(item.id, swap.revision) { + withFrameNanos { } + state.clearStripeSwap(item.id, swap.revision) + } + } + val offset = drag?.let { it.along - it.grab - state.stripeSlotTop(pinned.anchor, item.id, stripeHeight()) } + ?: swap?.offset Box( - id = "dock-stripe-button-${item.id}", + id = stripeButtonId(item.id), tags = buildList { add(DockTags.StripeButton) if (expanded) add(DockTags.Selected) + if (drag != null) add(DockTags.Dragging) }, - modifier = Modifier.size(StripeButtonSize.px, StripeButtonSize.px).input(hoverable = true, clickable = true) + modifier = Modifier.size(StripeButtonSize.px, StripeButtonSize.px) + .let { if (offset != null) it.translate(y = offset).transition() else it } + .layer(if (drag != null) 10 else 0) + .input(hoverable = true, clickable = true, draggable = true) .cursor(UiCursorShape.HAND).tooltipOnHover(item.title.lang, alignment = pinned.side.asideAlignment) - .onPlaced { anchor = it }.onPress { event -> + .onPlaced { anchor = it } + .onPress { event -> + // A drag whose release never arrived (the window lost focus mid-gesture) ends here. + state.stripeDrag = null if (event.isRightClick()) { menu = true event.consume() } - }.onClick { event -> - if (!event.isRightClick()) state.togglePinned(item.id) + } + .onDrag { event -> + if (event.isRightClick()) return@onDrag + val held = state.stripeDrag?.itemId == item.id + if (!held && hypot(event.dragTotalX, event.dragTotalY) < DragThreshold) return@onDrag + event.consume() + + if (abs(event.dragTotalX) > StripeButtonSize + UndockDistance) { + state.stripeDrag = null + val spaceX = event.localXInAncestor(DockTags.Space) ?: event.rootLocalX + val spaceY = event.localYInAncestor(DockTags.Space) ?: event.rootLocalY + state.undockPinned(item.id, spaceX - HeaderGrabX, spaceY - HeaderGrabY, stripeButtonId(item.id)) + return@onDrag + } + + val height = stripeHeight() + val along = event.localYInAncestor(stripeId(pinned.side)) ?: return@onDrag + if (height <= 0f) return@onDrag + val grab = state.stripeDrag?.takeIf { held }?.grab + ?: (along - state.stripeSlotTop(pinned.anchor, item.id, height)) + state.stripeDrag = DockStripeDrag(item.id, along, grab) + + val middle = along - grab + StripeButtonSize / 2f + val (target, index) = state.stripeSlotAt(pinned.side, item.id, middle, height) + val others = state.pinnedIn(target).count { it.item.id != item.id } + val before = state.pinnedIn(target).map { it.item.id } + if (!state.movePinned(item.id, target, index.coerceIn(0, others))) return@onDrag + if (target == pinned.anchor) { + val after = state.pinnedIn(target).map { it.item.id } + state.recordStripeSwaps( + after.withIndex() + .filter { (newIndex, id) -> id != item.id && before.indexOf(id) != newIndex } + .associate { (newIndex, id) -> id to (before.indexOf(id) - newIndex) * StripeButtonPitch }, + ) + } + } + .onRelease { event -> event.consume() + val dragged = state.stripeDrag?.itemId == item.id + state.stripeDrag = null + if (!dragged && !event.isRightClick()) state.togglePinned(item.id) }, ) { item.icon?.let { icon -> @@ -105,7 +262,14 @@ private fun DockStripeButton(pinned: DockPinnedItem, state: DockingState) { private fun stripeMenuItems(pinned: DockPinnedItem, state: DockingState): List { val itemId = pinned.item.id return buildList { - add(UiDropdownItem(label = DockLang.pinTo(pinned.side.opposite)) { state.pin(itemId, pinned.side.opposite) }) + DockStripeGroup.entries.filter { it != pinned.group }.forEach { group -> + add(UiDropdownItem(label = DockLang.moveTo(group)) { state.pin(itemId, pinned.side, group) }) + } + val panels = state.sidePanels(pinned.side) + if (panels.size == 2 && panels.any { it.item.id == itemId }) { + add(UiDropdownItem(label = DockLang.SwapPanels) { state.swapSidePanels(pinned.side) }) + } + add(UiDropdownItem(label = DockLang.pinTo(pinned.side.opposite)) { state.pin(itemId, pinned.side.opposite, pinned.group) }) add(UiDropdownItem(label = DockLang.Undock) { state.unpin(itemId) }) if (pinned.item.closable) { add(UiDropdownItem(label = DockLang.Close) { state.close(itemId) }) @@ -114,21 +278,26 @@ private fun stripeMenuItems(pinned: DockPinnedItem, state: DockingState): List + key(panel.item.id) { + if (index > 0) DockSideDivider(side, state) + val grow = when { + panels.size == 1 -> 1f + index == 0 -> fraction + else -> 1f - fraction + } + Column( + id = "dock-pinned-slot-${panel.item.id}", + modifier = Modifier.size(100.percent, 0.px).grow(grow).clip(), + ) { + DockPinnedBody(panel, state, tabBarActions, content) + } + } + } + } + if (side == DockSide.LEFT) DockPanelSplitter(side, state, open, sliding) +} + +/** The handle between a side panel's top window and the split one under it. */ +@Composable +private fun DockSideDivider(side: DockSide, state: DockingState) { + val fractionAtPress = remember { floatArrayOf(state.sideSplitFraction(side)) } + Box( + id = "dock-side-divider-${side.tag}", + tags = listOf(DockTags.Splitter), + modifier = Modifier.size(100.percent, PanelSplitterWidth.px) + .input(hoverable = true, draggable = true).cursor(UiCursorShape.RESIZE_VERTICAL) + .onPress { fractionAtPress[0] = state.sideSplitFraction(side) } + .onDrag { event -> + val height = event.parentHeight - PanelSplitterWidth + if (height > 0f) state.setSideSplitFraction(side, fractionAtPress[0] + event.dragTotalY / height) + event.consume() + }, + ) +} + +/** + * The area the bottom halves of both stripes. + */ +@Composable +internal fun DockBottomArea(state: DockingState, tabBarActions: DockTabBarActions, content: DockItemContent) { + val left = state.expandedIn(DockAnchor(DockSide.LEFT, DockStripeGroup.BOTTOM)) + val right = state.expandedIn(DockAnchor(DockSide.RIGHT, DockStripeGroup.BOTTOM)) + if (left == null && right == null) return + + val heightAtPress = remember { floatArrayOf(state.bottomHeight) } + Box( + id = "dock-bottom-splitter", + tags = listOf(DockTags.Splitter), + modifier = Modifier.size(100.percent, PanelSplitterWidth.px) + .input(hoverable = true, draggable = true).cursor(UiCursorShape.RESIZE_VERTICAL) + .onPress { heightAtPress[0] = state.bottomHeight } + .onDrag { event -> + state.setBottomHeight(heightAtPress[0] - event.dragTotalY) + event.consume() + }, + ) + Row( + id = "dock-bottom-area", + tags = listOf(DockTags.PinnedPanel, "bottom"), + modifier = Modifier.size(100.percent, state.bottomHeight.px).clip(), + ) { + val both = left != null && right != null + left?.let { panel -> + Column( + id = "dock-bottom-left", + modifier = Modifier.size(0.px, 100.percent).grow(if (both) state.bottomFraction else 1f).clip(), + ) { DockPinnedBody(panel, state, tabBarActions, content) } + } + if (both) DockBottomDivider(state) + right?.let { panel -> + Column( + id = "dock-bottom-right", + modifier = Modifier.size(0.px, 100.percent).grow(if (both) 1f - state.bottomFraction else 1f).clip(), + ) { DockPinnedBody(panel, state, tabBarActions, content) } + } + } +} + +@Composable +private fun DockBottomDivider(state: DockingState) { + val fractionAtPress = remember { floatArrayOf(state.bottomFraction) } + Box( + id = "dock-bottom-divider", + tags = listOf(DockTags.Splitter), + modifier = Modifier.size(PanelSplitterWidth.px, 100.percent) + .input(hoverable = true, draggable = true).cursor(UiCursorShape.RESIZE_HORIZONTAL) + .onPress { fractionAtPress[0] = state.bottomFraction } + .onDrag { event -> + val width = event.parentWidth - PanelSplitterWidth + if (width > 0f) state.setBottomFraction(fractionAtPress[0] + event.dragTotalX / width) + event.consume() + }, + ) +} + +/** A parked window as it shows when open: its header, then its content. */ +@Composable +private fun DockPinnedBody( + panel: DockPinnedItem, + state: DockingState, + tabBarActions: DockTabBarActions, + content: DockItemContent, +) { + val item = panel.item + Column(tags = listOf(DockTags.PinnedIsland)) { + if (!item.titleInToolbar) DockPanelHeader(panel, state, tabBarActions) Box( - id = "dock-pinned-content-${panel.item.id}", + id = "$PinnedContentPrefix${item.id}-content", tags = listOf(DockTags.Content), modifier = Modifier.size(100.percent, 0.px).grow(1f).clip().input(hoverable = true, clickable = true) - .onPress { state.expand(panel.item.id) }, + .onPress { state.expand(item.id) }, ) { - key(panel.item.id) { - DockContentBody { content(panel.item) } + key(item.id) { + val title = if (item.titleInToolbar) { + DockPanelTitle( + title = item.title.lang, + icon = item.icon, + headerId = pinnedHeaderId(item.id), + dragHandle = Modifier.pinnedHeaderDrag(item.id, state), + ) + } else null + CompositionLocalProvider(LocalDockPanelTitle provides title) { + DockContentBody { content(item) } + } } } } - if (side == DockSide.LEFT) DockPanelSplitter(panel, side, state, open, sliding) } @Composable -private fun DockPanelHeader(panel: DockPinnedItem, tabBarActions: DockTabBarActions) { +private fun DockPanelHeader(panel: DockPinnedItem, state: DockingState, tabBarActions: DockTabBarActions) { val item = panel.item Row( - id = "dock-pinned-header-${item.id}", + id = pinnedHeaderId(item.id), tags = listOf(DockTags.PinnedHeader), - modifier = Modifier.size(100.percent, 24.px).alignItems(vertical = UiAlign.CENTER), + modifier = Modifier.size(100.percent, 24.px).alignItems(vertical = UiAlign.CENTER) + .pinnedHeaderDrag(item.id, state), ) { item.icon?.let { icon -> Image(icon, tags = listOf(DockTags.PinnedHeaderIcon)) } Box(modifier = Modifier.size(0.px, 100.percent).grow(1f).clip()) { @@ -195,13 +483,12 @@ private fun DockPanelHeader(panel: DockPinnedItem, tabBarActions: DockTabBarActi @Composable private fun DockPanelSplitter( - panel: DockPinnedItem, side: DockSide, state: DockingState, open: Boolean, sliding: Boolean, ) { - val start = remember(panel.item.id) { floatArrayOf(panel.width) } + val start = remember(side) { floatArrayOf(state.sideWidth(side) ?: 0f) } Box( id = "dock-pinned-splitter-${side.tag}", tags = buildList { @@ -210,15 +497,15 @@ private fun DockPanelSplitter( }, modifier = Modifier.size(if (open) PanelSplitterWidth.px else 0.px, 100.percent) .input(hoverable = true, draggable = true).cursor(UiCursorShape.RESIZE_HORIZONTAL) - .onPress { start[0] = panel.width }.onDrag { event -> + .onPress { start[0] = state.sideWidth(side) ?: 0f }.onDrag { event -> val delta = if (side == DockSide.LEFT) event.dragTotalX else -event.dragTotalX - state.setPinnedWidth(panel.item.id, start[0] + delta) + state.setSideWidth(side, start[0] + delta) event.consume() }, ) } -private val DockSide.tag: String get() = if (this == DockSide.LEFT) "left" else "right" +internal val DockSide.tag: String get() = if (this == DockSide.LEFT) "left" else "right" private val DockSide.asideAlignment: UiPopupAlignment get() = if (this == DockSide.LEFT) { @@ -243,10 +530,23 @@ internal object DockLang { val PinLeft: String get() = "$Root.pin_left".lang val PinRight: String get() = "$Root.pin_right".lang + val PinLeftBottom: String get() = "$Root.pin_left_bottom".lang + val PinRightBottom: String get() = "$Root.pin_right_bottom".lang + val PinLeftSplit: String get() = "$Root.pin_left_split".lang + val PinRightSplit: String get() = "$Root.pin_right_split".lang val Undock: String get() = "$Root.undock".lang val Close: String get() = "$Root.close".lang val HideStripes: String get() = "$Root.hide_stripes".lang val ShowStripes: String get() = "$Root.show_stripes".lang + val ParkHere: String get() = "$Root.park_here".lang fun pinTo(side: DockSide): String = if (side == DockSide.LEFT) PinLeft else PinRight + + val SwapPanels: String get() = "$Root.swap_panels".lang + + fun moveTo(group: DockStripeGroup): String = when (group) { + DockStripeGroup.TOP -> "$Root.move_to_top".lang + DockStripeGroup.SPLIT -> "$Root.move_to_split".lang + DockStripeGroup.BOTTOM -> "$Root.move_to_bottom".lang + } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingCompose.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingCompose.kt index a3e04c412..a4dc3e8f6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingCompose.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingCompose.kt @@ -11,9 +11,16 @@ import ru.hollowhorizon.hollowengine.client.utils.lang private const val DockTabMinWidth = 72f private const val DockTabMaxWidth = 180f -private const val DockTabHeight = 18f + +private const val DockTabBarHeight = 24f +private const val DockTabTopGap = 3f +private const val DockTabHeight = DockTabBarHeight - DockTabTopGap +private const val DockTabOverhang = 6f +private const val DockTabIndicatorHeight = 2f private const val DockTabMargin = 2f private const val DockTabCloseWidth = 22f +private const val DockTabCloseSize = 16f +private const val DockCloseIcon = "hollowengine:textures/gui/icons/cross.svg" /** A dragged tab lifts slightly off the bar, and forward, so it draws over its neighbours. */ private const val DockTabDragLift = -2f @@ -38,23 +45,31 @@ fun DockSpace( Box( id = id, tags = listOf(DockTags.Space), - modifier = modifier.style("hollowengine:ui/styles/docking.hss").clip(), + modifier = modifier.style("hollowengine:ui/styles/docking.hss").clip() + .onPlaced { state.updateSpaceSize(it.width, it.height) }, ) { Row(id = "$id-body", modifier = Modifier.size(100.percent, 100.percent)) { - DockSideEdge(DockSide.LEFT, state, tabBarActions, content) - Box( - id = "$id-root", - mode = UiBoxMode.STACK, - tags = listOf(DockTags.Root), - modifier = Modifier.size(0.px, 100.percent).grow(1f), - ) { - state.root?.let { root -> - key(root.id) { - DockNodeView(root, state, tabContent, tabBarActions, content) + DockStripe(DockSide.LEFT, state) + Column(id = "$id-center", modifier = Modifier.size(0.px, 100.percent).grow(1f)) { + Row(id = "$id-upper", modifier = Modifier.size(100.percent, 0.px).grow(1f)) { + DockSidePanel(DockSide.LEFT, state, tabBarActions, content) + Box( + id = "$id-root", + mode = UiBoxMode.STACK, + tags = listOf(DockTags.Root), + modifier = Modifier.size(0.px, 100.percent).grow(1f), + ) { + state.root?.let { root -> + key(root.id) { + DockNodeView(root, state, tabContent, tabBarActions, content) + } + } } + DockSidePanel(DockSide.RIGHT, state, tabBarActions, content) } + DockBottomArea(state, tabBarActions, content) } - DockSideEdge(DockSide.RIGHT, state, tabBarActions, content) + DockStripe(DockSide.RIGHT, state) } state.floatingWindows.forEachIndexed { index, window -> @@ -64,7 +79,13 @@ fun DockSpace( } if (state.draggedWindowId != null) { - DockDropOverlay(state, state.edgeWidth(DockSide.LEFT), state.edgeWidth(DockSide.RIGHT)) + DockDropOverlay( + state, + state.edgeWidth(DockSide.LEFT), + state.edgeWidth(DockSide.RIGHT), + state.bottomAreaHeight(), + ) + if (state.canPinDraggedWindow()) DockStripeDropZones(state) } } } @@ -157,19 +178,6 @@ private fun DockStackView( content: DockItemContent, ) { val selected = stack.selectedItem ?: return - if (stack.items.size == 1 && selected.singleTabPresentation == DockSingleTabPresentation.OVERLAY) { - Box( - id = stack.id, - mode = UiBoxMode.STACK, - tags = listOf(DockTags.Stack), - modifier = Modifier.size(100.percent, 100.percent), - ) { - DockSelectedContent(stack.id, selected, content, Modifier.size(100.percent, 100.percent)) - DockSingleTabBadge(stack, state, tabContent, tabBarActions) - } - return - } - Column( id = stack.id, tags = listOf(DockTags.Stack), @@ -213,27 +221,6 @@ internal fun DockContentBody(content: @Composable () -> Unit) { } } -@Composable -private fun DockSingleTabBadge( - stack: DockNode.Stack, - state: DockingState, - tabContent: DockHeaderContent, - tabBarActions: DockTabBarActions, -) { - val item = stack.selectedItem ?: return - Row( - id = "${stack.id}-tab-badge", - tags = listOf(DockTags.TabBar, DockTags.CompactTabBar), - modifier = Modifier.position(DockTabMargin.px, DockTabMargin.px) - .size(UiLength.Auto, 24.px) - .alignItems(vertical = UiAlign.CENTER) - .layer(20), - ) { - DockTab(stack.id, 0, item, true, state, tabContent, allowUndock = true) { 0f } - tabBarActions(item) - } -} - @Composable private fun FloatingDockWindowView( window: FloatingDockWindow, @@ -318,7 +305,7 @@ private fun DockTabBar( } Row( id = "${stack.id}-tab-row", - modifier = Modifier.size(100.percent, 24.px).alignItems(vertical = UiAlign.CENTER), + modifier = Modifier.size(100.percent, DockTabBarHeight.px).alignItems(vertical = UiAlign.CENTER), ) { Layout( content = { @@ -339,7 +326,7 @@ private fun DockTabBar( }, id = "${stack.id}-tabs", tags = listOf(DockTags.TabBar), - modifier = Modifier.size(0.px, 24.px).grow(1f).clip() + modifier = Modifier.size(0.px, DockTabBarHeight.px).grow(1f).clip() .scrollable( vertical = false, horizontal = true, @@ -387,15 +374,16 @@ private fun DockTab( var menuAt by remember { mutableStateOf(null) } - Row( + Column( id = tabNodeId(item.id), tags = buildList { add(DockTags.Tab) if (selected) add(DockTags.Selected) if (item.dirty) add(DockTags.Dirty) }, - modifier = Modifier.size(width = UiLength.Auto, height = DockTabHeight.px).minSize(width = DockTabMinWidth.px) - .maxSize(width = DockTabMaxWidth.px).alignItems(vertical = UiAlign.CENTER).layer(layerIndex).clip() + modifier = Modifier.size(width = UiLength.Auto, height = (DockTabHeight + DockTabOverhang).px) + .minSize(width = DockTabMinWidth.px).maxSize(width = DockTabMaxWidth.px) + .alignItems(horizontal = UiAlign.STRETCH).layer(layerIndex).clip() .tabTransform(dragOffset, DockTabOffset.DRAG).tabTransform(swap?.offset, DockTabOffset.SWAP) .cursor(if (dragOffset != null) UiCursorShape.MOVE else UiCursorShape.HAND) .input(hoverable = true, clickable = true, draggable = true) @@ -404,9 +392,14 @@ private fun DockTab( else state.onTabContextMenu?.invoke(item, event) } ) { - TabContentWrapper(item, tabContent) - - CloseButton(item, state) + Row( + modifier = Modifier.size(UiLength.Auto, (DockTabHeight - DockTabIndicatorHeight).px) + .alignItems(vertical = UiAlign.CENTER), + ) { + TabContentWrapper(item, tabContent) + CloseButton(item, state) + } + Box(tags = listOf(DockTags.TabIndicator), modifier = Modifier.size(UiLength.Auto, DockTabIndicatorHeight.px)) } menuAt?.let { anchor -> @@ -423,6 +416,10 @@ private fun DockTab( private fun tabMenuItems(item: DockItem, state: DockingState): List = buildList { add(UiDropdownItem(label = DockLang.PinLeft) { state.pin(item.id, DockSide.LEFT) }) add(UiDropdownItem(label = DockLang.PinRight) { state.pin(item.id, DockSide.RIGHT) }) + add(UiDropdownItem(label = DockLang.PinLeftSplit) { state.pin(item.id, DockSide.LEFT, DockStripeGroup.SPLIT) }) + add(UiDropdownItem(label = DockLang.PinRightSplit) { state.pin(item.id, DockSide.RIGHT, DockStripeGroup.SPLIT) }) + add(UiDropdownItem(label = DockLang.PinLeftBottom) { state.pin(item.id, DockSide.LEFT, DockStripeGroup.BOTTOM) }) + add(UiDropdownItem(label = DockLang.PinRightBottom) { state.pin(item.id, DockSide.RIGHT, DockStripeGroup.BOTTOM) }) if (item.closable) { add(UiDropdownItem(label = DockLang.Close, separatorBefore = true) { state.close(item.id) }) } @@ -535,7 +532,7 @@ private fun dockTabBarMeasurePolicy( val placeable = measurable.measure( UiConstraints( maxWidth = constraints.maxWidth, - maxHeight = DockTabHeight, + maxHeight = DockTabHeight + DockTabOverhang, ) ) val itemId = itemIds.getOrNull(index) ?: measurable.node.id.orEmpty() @@ -551,10 +548,10 @@ private fun dockTabBarMeasurePolicy( } state.updateTabLayouts(stackId, layouts) val width = constraints.maxWidth.takeIf { it.isFinite() } ?: x - layout(width, DockTabHeight + DockTabMargin * 2f) { + layout(width, DockTabBarHeight) { var childX = 0f placeables.forEach { placeable -> - placeable.place(childX + DockTabMargin, DockTabMargin) + placeable.place(childX + DockTabMargin, DockTabTopGap) childX += placeable.width + DockTabMargin * 2f } } @@ -576,12 +573,13 @@ private fun CloseButton(item: DockItem, state: DockingState) { Box( id = "dock-close-${item.id}", tags = listOf(DockTags.CloseButton), - modifier = Modifier.size(DockTabCloseWidth.px, 100.percent).input(hoverable = true, clickable = true) + mode = UiBoxMode.STACK, + modifier = Modifier.size(DockTabCloseSize.px, DockTabCloseSize.px).input(hoverable = true, clickable = true) .cursor(UiCursorShape.HAND).onClick { event -> state.close(item.id) event.consume() }) { - Text("x", modifier = Modifier.align(UiAlign.CENTER, UiAlign.CENTER)) + Image(DockCloseIcon, tags = listOf(DockTags.CloseIcon), modifier = Modifier.align(UiAlign.CENTER, UiAlign.CENTER)) } } @@ -629,14 +627,16 @@ object DockTags { const val Splitter = "dock-splitter" const val Stack = "dock-stack" const val TabBar = "dock-tab-bar" - const val CompactTabBar = "compact" const val Tab = "dock-tab" const val TabIcon = "dock-tab-icon" const val TabLabel = "dock-tab-label" const val Selected = "selected" + const val Dragging = "dragging" const val Stripe = "dock-stripe" const val StripeButton = "dock-stripe-button" const val StripeIcon = "dock-stripe-icon" + const val StripeDivider = "dock-stripe-divider" + const val DropPreview = "dock-drop-preview" const val PinnedPanel = "dock-pinned-panel" const val Sliding = "sliding" const val PinnedHeader = "dock-pinned-header" @@ -649,13 +649,11 @@ object DockTags { const val ContentBody = "dock-content-body" const val ResizeHandle = "dock-resize-handle" const val CloseButton = "dock-close-button" + const val CloseIcon = "dock-close-icon" + const val TabIndicator = "dock-tab-indicator" + const val PinnedIsland = "dock-pinned-island" const val DropOverlay = "dock-drop-overlay" const val DropZone = "dock-drop-zone" -} - -object DockColors { - val DropZone = UiColor(0.18f, 0.42f, 0.75f, 0.1f) - val DropZoneActive = UiColor(0.25f, 0.58f, 0.95f, 0.42f) - val DropZoneBorder = UiColor(0.44f, 0.68f, 0.95f, 0.28f) - val DropZoneBorderActive = UiColor(0.7f, 0.86f, 1f, 0.95f) + const val DropZoneGlyph = "dock-drop-zone-glyph" + const val Active = "active" } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingOverlayCompose.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingOverlayCompose.kt index 55b9ff01c..11e77cd6d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingOverlayCompose.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingOverlayCompose.kt @@ -1,8 +1,13 @@ package ru.hollowhorizon.hollowengine.client.ui.docking import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover @Composable internal fun FloatingResizeHandle(window: FloatingDockWindow, state: DockingState) { @@ -11,11 +16,8 @@ internal fun FloatingResizeHandle(window: FloatingDockWindow, state: DockingStat Box( id = "${window.id}-resize-${edge.name.lowercase()}", tags = listOf(DockTags.ResizeHandle), - modifier = Modifier.resizeHandleModifier(edge) - .input(hoverable = true, draggable = true) - .cursor(edge.cursorShape) - .onPress { dragStart[0] = window } - .onDrag { event -> + modifier = Modifier.resizeHandleModifier(edge).input(hoverable = true, draggable = true) + .cursor(edge.cursorShape).onPress { dragStart[0] = window }.onDrag { event -> state.resizeFloatingFrom( window.id, edge, @@ -24,34 +26,64 @@ internal fun FloatingResizeHandle(window: FloatingDockWindow, state: DockingStat event.dragTotalY, ) event.consume() - } - ) + }) } } @Composable -internal fun DockDropOverlay(state: DockingState, leftInset: Float = 0f, rightInset: Float = 0f) { +internal fun DockDropOverlay( + state: DockingState, + leftInset: Float = 0f, + rightInset: Float = 0f, + bottomInset: Float = 0f, +) { + var bounds by remember { mutableStateOf(UiRect.Zero) } Box( id = "dock-drop-overlay", tags = listOf(DockTags.DropOverlay), - modifier = Modifier.size(100.percent, 100.percent) - .padding(leftInset.px, 0.px, rightInset.px, 0.px) - .layer(10_000) - ) { + modifier = Modifier.size(100.percent, 100.percent).padding(leftInset.px, 0.px, rightInset.px, bottomInset.px) + .layer(10_000).onPlaced { bounds = it }) { val root = state.root + val layouts = DockLayoutCalculator.layout(root, DockBounds) + DockDropPreview( + state, + layouts, + width = bounds.width - leftInset - rightInset, + height = bounds.height - bottomInset, + ) if (root == null) { - DockPlusDropZones(state, DockTarget.Root, DockRect(0f, 0f, 100f, 100f), 1) + DockPlusDropZones(state, DockTarget.Root, DockBounds, 1) return@Box } - DockLayoutCalculator.layout(root, DockRect(0f, 0f, 100f, 100f)) - .filter { it.stack } - .forEach { layout -> - DockPlusDropZones(state, DockTarget(anchorId = layout.nodeId), layout.rect, 1) - } + layouts.filter { it.stack }.forEach { layout -> + DockPlusDropZones(state, DockTarget(anchorId = layout.nodeId), layout.rect, 1) + } DockRootDropZones(state) } } +/** + * Outlines where the dragged window would land. + */ +@Composable +private fun DockDropPreview(state: DockingState, layouts: List, width: Float, height: Float) { + val target = state.previewTarget?.let { dockPreviewRect(it, layouts, DockBounds) } + val last = remember { arrayOfNulls(1) } + if (target != null) last[0] = target + val rect = target ?: last[0] ?: return + if (width <= 0f || height <= 0f) return + val scaleX = width / DockBounds.width + val scaleY = height / DockBounds.height + Box( + id = "dock-drop-preview", + tags = listOfNotNull(DockTags.DropPreview, "visible".takeIf { target != null }), + modifier = Modifier.position(0.px, 0.px).size( + (rect.width * scaleX - DropPreviewInset * 2f).coerceAtLeast(0f).px, + (rect.height * scaleY - DropPreviewInset * 2f).coerceAtLeast(0f).px + ).translate(rect.x * scaleX + DropPreviewInset, rect.y * scaleY + DropPreviewInset).inputTransparent(), + ) +} + @Composable private fun DockRootDropZones(state: DockingState) { DockDropZone(state, DockTarget(placement = DockPlacement.LEFT), width = 25.px, height = 125.px, layer = 2) @@ -127,20 +159,14 @@ private fun DockDropZone( val active = state.previewTarget == target Box( id = "dock-drop-${target.anchorId ?: "root"}-${target.placement.name.lowercase()}", - tags = listOf(DockTags.DropZone), - modifier = Modifier.size(width, height) - .layer(layer) - .background(if (active) DockColors.DropZoneActive else DockColors.DropZone) - .border(1.px, if (active) DockColors.DropZoneBorderActive else DockColors.DropZoneBorder) - .input(hoverable = true, clickable = true) - .cursor(UiCursorShape.HAND) - .onHover { + mode = UiBoxMode.STACK, + tags = listOfNotNull(DockTags.DropZone, DockTags.Active.takeIf { active }), + modifier = Modifier.size(width, height).layer(layer).input(hoverable = true, clickable = true) + .cursor(UiCursorShape.HAND).onHover { state.previewDock(target) - } - .onExit { + }.onExit { if (state.previewTarget == target) state.previewDock(null) - } - .onRelease { event -> + }.onRelease { event -> state.dockDraggedWindow(target) event.consume() }.then( @@ -158,6 +184,24 @@ private fun DockDropZone( } } else Modifier.position(x, y) ) + ) { + DropZoneGlyph(target.placement) + } +} + +/** A small picture inside a zone of the part of the pane the window would take. */ +@Composable +private fun DropZoneGlyph(placement: DockPlacement) { + val (width, height) = when (placement) { + DockPlacement.LEFT, DockPlacement.RIGHT -> 50.percent to 100.percent + DockPlacement.TOP, DockPlacement.BOTTOM -> 100.percent to 50.percent + DockPlacement.CENTER -> 100.percent to 100.percent + } + val horizontal = if (placement == DockPlacement.RIGHT) UiAlign.END else UiAlign.START + val vertical = if (placement == DockPlacement.BOTTOM) UiAlign.END else UiAlign.START + Box( + tags = listOf(DockTags.DropZoneGlyph), + modifier = Modifier.size(width, height).align(horizontal, vertical).inputTransparent(), ) } @@ -180,10 +224,7 @@ private fun Modifier.resizeHandleModifier(edge: DockResizeEdge): Modifier { } private val DockResizeEdge.isCorner: Boolean - get() = this == DockResizeEdge.TOP_LEFT || - this == DockResizeEdge.TOP_RIGHT || - this == DockResizeEdge.BOTTOM_LEFT || - this == DockResizeEdge.BOTTOM_RIGHT + get() = this == DockResizeEdge.TOP_LEFT || this == DockResizeEdge.TOP_RIGHT || this == DockResizeEdge.BOTTOM_LEFT || this == DockResizeEdge.BOTTOM_RIGHT private val DockResizeEdge.cursorShape: UiCursorShape get() = when (this) { @@ -203,3 +244,44 @@ private val DockResizeEdge.cursorShape: UiCursorShape DockResizeEdge.BOTTOM_LEFT, -> UiCursorShape.RESIZE_NESW } + +@Composable +internal fun DockStripeDropZones(state: DockingState) { + var hovered by remember { mutableStateOf(null) } + Box( + id = "dock-stripe-drop-zones", + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).layer(10_001).inputTransparent(), + ) { + DockAnchor.all.forEach { anchor -> + val active = hovered == anchor + val horizontal = if (anchor.side == DockSide.LEFT) UiAlign.START else UiAlign.END + val vertical = when (anchor.group) { + DockStripeGroup.TOP -> UiAlign.START + DockStripeGroup.SPLIT -> UiAlign.CENTER + DockStripeGroup.BOTTOM -> UiAlign.END + } + Box( + id = "dock-stripe-drop-${anchor.side.tag}-${anchor.group.name.lowercase()}", + tags = listOfNotNull(DockTags.DropZone, DockTags.Active.takeIf { active }), + modifier = Modifier.size(StripeDropWidth.px, StripeDropHeight.percent).align(horizontal, vertical) + .input(hoverable = true, clickable = true).tooltipOnHover(DockLang.ParkHere) + .onHover { hovered = anchor }.onExit { if (hovered == anchor) hovered = null }.onRelease { event -> + state.pinDraggedWindow(anchor) + event.consume() + }, + ) + } + } +} + +private const val StripeDropWidth = 28f + +private const val StripeDropHeight = 32f +private val DockBounds = DockRect(0f, 0f, 100f, 100f) +private const val DropPreviewInset = 3f + +internal fun DockingState.bottomAreaHeight(): Float { + val open = DockSide.entries.any { side -> expandedIn(DockAnchor(side, DockStripeGroup.BOTTOM)) != null } + return if (open) bottomHeight + PanelSplitterWidth else 0f +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingPinned.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingPinned.kt new file mode 100644 index 000000000..2673a1f3f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingPinned.kt @@ -0,0 +1,247 @@ +package ru.hollowhorizon.hollowengine.client.ui.docking + +private const val MinPinnedWidth = 60f +private const val MaxPinnedWidth = 900f +private const val MinBottomHeight = 60f +private const val MaxBottomHeight = 900f +private const val MinBottomFraction = 0.15f +private const val MaxBottomFraction = 0.85f +private const val DefaultSideSplitFraction = 0.5f +private const val MinSideSplitFraction = 0.12f +private const val MaxSideSplitFraction = 0.88f + +/** The stripe buttons on [side], in the order they are drawn. */ +fun DockingState.pinnedOn(side: DockSide): List = + DockStripeGroup.entries.flatMap { group -> pinnedIn(DockAnchor(side, group)) } + +fun DockingState.pinnedIn(anchor: DockAnchor): List = pinnedItems.filter { it.anchor == anchor } + +/** The window open in [anchor], or null while that half is collapsed. */ +fun DockingState.expandedIn(anchor: DockAnchor): DockPinnedItem? { + val itemId = expandedByAnchor[anchor] ?: return null + return pinnedItems.firstOrNull { it.item.id == itemId && it.anchor == anchor } +} + +/** The window open in the top part of [side]'s stripe. */ +fun DockingState.expandedOn(side: DockSide): DockPinnedItem? = expandedIn(DockAnchor(side)) + +/** What the side panel of [side] shows, top to bottom: its top window, then the split one under it. */ +fun DockingState.sidePanels(side: DockSide): List = listOfNotNull( + expandedIn(DockAnchor(side, DockStripeGroup.TOP)), + expandedIn(DockAnchor(side, DockStripeGroup.SPLIT)), +) + +/** The side panel's width; the windows in it share one, the top one's when both are open. */ +fun DockingState.sideWidth(side: DockSide): Float? = sidePanels(side).firstOrNull()?.width + +fun DockingState.sideSplitFraction(side: DockSide): Float = sideSplitFractions[side] ?: DefaultSideSplitFraction + +fun DockingState.setSideSplitFraction(side: DockSide, fraction: Float) { + sideSplitFractions[side] = fraction.coerceIn(MinSideSplitFraction, MaxSideSplitFraction) +} + +/** Sets the width of every window the side panel shows, so they stay one column. */ +fun DockingState.setSideWidth(side: DockSide, width: Float) { + sidePanels(side).forEach { setPinnedWidth(it.item.id, width) } +} + +/** Swaps the side panel's two windows, keeping both open. */ +fun DockingState.swapSidePanels(side: DockSide): Boolean { + val top = expandedIn(DockAnchor(side, DockStripeGroup.TOP)) ?: return false + val split = expandedIn(DockAnchor(side, DockStripeGroup.SPLIT)) ?: return false + val topIndex = pinnedItems.indexOf(top) + val splitIndex = pinnedItems.indexOf(split) + pinnedItems[topIndex] = split.copy(group = DockStripeGroup.TOP, width = top.width) + pinnedItems[splitIndex] = top.copy(group = DockStripeGroup.SPLIT) + expandedByAnchor[DockAnchor(side, DockStripeGroup.TOP)] = split.item.id + expandedByAnchor[DockAnchor(side, DockStripeGroup.SPLIT)] = top.item.id + return true +} + +fun DockingState.pinnedItem(itemId: String): DockPinnedItem? = pinnedItems.firstOrNull { it.item.id == itemId } + +fun DockingState.isPinned(itemId: String): Boolean = pinnedItem(itemId) != null + +/** + * Parks a tool window on a stripe and opens it there. + */ +fun DockingState.pin(itemId: String, side: DockSide, group: DockStripeGroup = DockStripeGroup.TOP): Boolean { + val anchor = DockAnchor(side, group) + if (isPinned(itemId)) { + movePinned(itemId, anchor, pinnedIn(anchor).size) + return expand(itemId) + } + + val item = item(itemId) ?: return false + if (!item.pinnable) return false + removeItemForDock(itemId) ?: return false + pinnedItems += DockPinnedItem(item, side, DefaultPinnedWidth.coerceAtLeast(item.minWidth), group) + stripesVisible = true + return expand(itemId) +} + +/** + * Moves a parked window to [index] among the buttons of [anchor]. + */ +fun DockingState.movePinned(itemId: String, anchor: DockAnchor, index: Int): Boolean { + val current = pinnedItem(itemId) ?: return false + val wasOpen = expandedByAnchor[current.anchor] == itemId + val others = pinnedItems.filterNot { it.item.id == itemId } + val moved = current.copy(side = anchor.side, group = anchor.group) + + val before = others.filter { it.anchor == anchor }.getOrNull(index.coerceAtLeast(0)) + val position = before?.let { others.indexOf(it) } ?: run { + val lastOfAnchor = others.indexOfLast { it.anchor == anchor } + if (lastOfAnchor >= 0) lastOfAnchor + 1 else others.size + } + val next = others.toMutableList().apply { add(position, moved) } + if (next == pinnedItems.toList()) return false + + pinnedItems.clear() + pinnedItems += next + if (wasOpen && current.anchor != anchor) { + expandedByAnchor.remove(current.anchor) + expandedByAnchor[anchor] = itemId + } + return true +} + +/** Takes a pinned item off its stripe and docks it back into the tree at [target]. */ +fun DockingState.unpin(itemId: String, target: DockTarget = DockTarget.Root): Boolean { + val pinned = takeOffStripe(itemId) ?: return false + open(pinned.item, target) + return true +} + +/** + * Takes a pinned item off its stripe into a floating window that is being dragged. + */ +fun DockingState.undockPinned(itemId: String, x: Float, y: Float, dragKey: String): DockWindowDragStart? { + val pinned = takeOffStripe(itemId) ?: return null + val item = pinned.item + val stack = newStack(listOf(item), item.id) ?: return null + val window = newWindow( + stack, + x, + y, + pinned.width.coerceAtLeast(item.minWidth), + FloatingHeight.coerceAtLeast(item.minHeight), + ).copy(dragKey = dragKey) + floatingWindows += window + startDraggingWindow(window.id) + focus(item.id) + return DockWindowDragStart(window.id, created = true) +} + +/** Whether the window being dragged could be parked on a stripe. */ +fun DockingState.canPinDraggedWindow(): Boolean { + val window = floatingWindows.firstOrNull { it.id == draggedWindowId } ?: return false + return window.stack.items.all { it.pinnable } +} + +/** Parks every window of the dragged floating window in [anchor], and opens the one on show. */ +fun DockingState.pinDraggedWindow(anchor: DockAnchor): Boolean { + val index = floatingWindows.indexOfFirst { it.id == draggedWindowId } + if (index < 0 || !canPinDraggedWindow()) { + finishDraggingWindow() + return false + } + val window = floatingWindows.removeAt(index) + window.stack.items.forEach { item -> + pinnedItems += DockPinnedItem(item, anchor.side, window.width.coerceAtLeast(item.minWidth), anchor.group) + } + stripesVisible = true + finishDraggingWindow() + return expand(window.stack.selectedItem?.id ?: window.stack.items.first().id) +} + +private fun DockingState.takeOffStripe(itemId: String): DockPinnedItem? { + val index = pinnedItems.indexOfFirst { it.item.id == itemId } + if (index < 0) return null + val pinned = pinnedItems.removeAt(index) + if (expandedByAnchor[pinned.anchor] == itemId) expandedByAnchor.remove(pinned.anchor) + return pinned +} + +/** Opens a pinned item's panel, collapsing whatever its half of the stripe had open. */ +fun DockingState.expand(itemId: String): Boolean { + val pinned = pinnedItem(itemId) ?: return false + expandedByAnchor[pinned.anchor] = itemId + focusedItemId = itemId + return true +} + +/** Collapses [anchor] back to its bare buttons. */ +fun DockingState.collapse(anchor: DockAnchor) { + val itemId = expandedByAnchor.remove(anchor) ?: return + if (focusedItemId == itemId) focusedItemId = firstItemId() +} + +/** Is the current panel visible regardless of its mode. */ +fun DockingState.isOnScreen(itemId: String): Boolean { + val pinned = pinnedItem(itemId) ?: return contains(itemId) + return expandedByAnchor[pinned.anchor] == itemId +} + +/** Turns a window off, or back on, wherever it lives. */ +fun DockingState.toggleOnScreen(itemId: String): Boolean { + if (isPinned(itemId)) return togglePinned(itemId) + if (!contains(itemId)) return false + return close(itemId) +} + +/** Opens that panel, or closes it if it was already open. */ +fun DockingState.togglePinned(itemId: String): Boolean { + val pinned = pinnedItem(itemId) ?: return false + if (expandedByAnchor[pinned.anchor] == itemId) { + collapse(pinned.anchor) + return true + } + return expand(itemId) +} + +/** Remembers the width the user dragged a side panel to. */ +fun DockingState.setPinnedWidth(itemId: String, width: Float): Boolean { + val index = pinnedItems.indexOfFirst { it.item.id == itemId } + if (index < 0) return false + val pinned = pinnedItems[index] + val next = width.coerceIn(pinned.item.minWidth.coerceAtLeast(MinPinnedWidth), MaxPinnedWidth) + if (next == pinned.width) return false + pinnedItems[index] = pinned.copy(width = next) + return true +} + +fun DockingState.setBottomHeight(height: Float) { + bottomHeight = height.coerceIn(MinBottomHeight, MaxBottomHeight) +} + +fun DockingState.setBottomFraction(fraction: Float) { + bottomFraction = fraction.coerceIn(MinBottomFraction, MaxBottomFraction) +} + +/** Restores the stripes from a stored layout, dropping items the tree already holds. */ +fun DockingState.applyPinned( + items: List, + expanded: Map, + visible: Boolean = true, + bottomHeight: Float = DefaultBottomHeight, + bottomFraction: Float = 0.5f, + sideSplit: Map = emptyMap(), +) { + pinnedItems.clear() + pinnedItems += items.filterNot { contains(it.item.id) } + expandedByAnchor.clear() + expanded.forEach { (anchor, itemId) -> + if (pinnedItems.any { it.item.id == itemId && it.anchor == anchor }) expandedByAnchor[anchor] = itemId + } + stripesVisible = visible + setBottomHeight(bottomHeight) + setBottomFraction(bottomFraction) + sideSplitFractions.clear() + sideSplit.forEach { (side, fraction) -> setSideSplitFraction(side, fraction) } +} + +/** Which item each half has open, for storing the layout. */ +fun DockingState.expandedPinned(): Map = expandedByAnchor.toMap() + +private const val FloatingHeight = 220f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingState.kt index 4bafe402f..0894f666f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingState.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/docking/DockingState.kt @@ -5,8 +5,6 @@ import ru.hollowhorizon.hollowengine.client.ui.UiEvent private const val MinSplitFraction = 0.1f private const val MaxSplitFraction = 0.9f -private const val MinPinnedWidth = 60f -private const val MaxPinnedWidth = 900f class DockingState { private val ids = DockIdGenerator() @@ -19,7 +17,7 @@ class DockingState { val floatingWindows = mutableStateListOf() var focusedItemId: String? by mutableStateOf(null) - private set + internal set var draggedWindowId: String? by mutableStateOf(null) private set @@ -33,12 +31,41 @@ class DockingState { /** Tool windows parked on the side stripes, in the order their buttons appear. */ val pinnedItems = mutableStateListOf() - /** The item each side currently has expanded into a panel, by side. */ - private val expandedBySide = mutableStateMapOf() + /** The item each stripe half currently has open, by where it is parked. */ + internal val expandedByAnchor = mutableStateMapOf() /** Whether the stripes are drawn at all. Hidden, the editor runs edge to edge. */ var stripesVisible: Boolean by mutableStateOf(true) + /** Height of the area under the editor and the side panels that the bottom halves open into. */ + var bottomHeight: Float by mutableStateOf(DefaultBottomHeight) + internal set + + /** How much of the bottom area the left stripe's window takes when both bottom halves are open. */ + var bottomFraction: Float by mutableStateOf(0.5f) + internal set + + /** How much of a side panel its top window takes when a split window is open under it. */ + internal val sideSplitFractions = mutableStateMapOf() + + /** The stripe button being dragged, if any. */ + var stripeDrag: DockStripeDrag? by mutableStateOf(null) + internal set + + /** Stripe buttons a reorder displaced, by how far they still sit from their new place; see [DockTabSwap]. */ + internal val stripeSwapOffsets = mutableStateMapOf() + private var stripeSwapRevision = 0L + + internal fun recordStripeSwaps(offsets: Map) { + if (offsets.isEmpty()) return + val revision = ++stripeSwapRevision + offsets.forEach { (itemId, offset) -> stripeSwapOffsets[itemId] = DockTabSwap(offset, revision) } + } + + internal fun clearStripeSwap(itemId: String, revision: Long) { + if (stripeSwapOffsets[itemId]?.revision == revision) stripeSwapOffsets.remove(itemId) + } + private var tabGrab: DockTabGrabState? by mutableStateOf(null) private val tabSwapOffsets = mutableStateMapOf() private var tabSwapRevision = 0L @@ -101,7 +128,7 @@ class DockingState { floatingWindows.clear() floatingWindows += floating pinnedItems.clear() - expandedBySide.clear() + expandedByAnchor.clear() focusedItemId = focused?.takeIf(::contains) ?: firstItemId() focusedItemId?.let { this.root = this.root?.select(it) } } @@ -110,7 +137,7 @@ class DockingState { val pinnedIndex = pinnedItems.indexOfFirst { it.item.id == itemId } if (pinnedIndex >= 0) { val pinned = pinnedItems.removeAt(pinnedIndex) - if (expandedBySide[pinned.side] == itemId) expandedBySide.remove(pinned.side) + if (expandedByAnchor[pinned.anchor] == itemId) expandedByAnchor.remove(pinned.anchor) if (focusedItemId == itemId) focusedItemId = firstItemId() return true } @@ -158,6 +185,9 @@ class DockingState { } fun focusContent(contentNodeId: String): Boolean { + if (contentNodeId.startsWith(PinnedContentPrefix)) { + return expand(contentNodeId.removePrefix(PinnedContentPrefix).removeSuffix("-content")) + } val containerId = contentNodeId.removeSuffix("-content") val stack = findStack(containerId) ?: floatingWindows.firstOrNull { it.id == containerId }?.stack @@ -176,115 +206,6 @@ class DockingState { focusedItemId = null } - /** The stripe buttons on [side], in the order they are drawn. */ - fun pinnedOn(side: DockSide): List = pinnedItems.filter { it.side == side } - - /** The item [side] currently has expanded into a panel, or null while the side is collapsed. */ - fun expandedOn(side: DockSide): DockPinnedItem? { - val itemId = expandedBySide[side] ?: return null - return pinnedItems.firstOrNull { it.item.id == itemId && it.side == side } - } - - fun pinnedItem(itemId: String): DockPinnedItem? = pinnedItems.firstOrNull { it.item.id == itemId } - - fun isPinned(itemId: String): Boolean = pinnedItem(itemId) != null - - /** - * Parks a tool window on [side]'s stripe and expands it there. An item already on a stripe just - * moves to the other side, keeping the width it had. - */ - fun pin(itemId: String, side: DockSide): Boolean { - val index = pinnedItems.indexOfFirst { it.item.id == itemId } - if (index >= 0) { - val current = pinnedItems[index] - if (current.side == side) return expand(itemId) - if (expandedBySide[current.side] == itemId) expandedBySide.remove(current.side) - pinnedItems[index] = current.copy(side = side) - return expand(itemId) - } - - val item = item(itemId) ?: return false - if (!item.pinnable) return false - removeItemForDock(itemId) ?: return false - pinnedItems += DockPinnedItem(item, side, DefaultPinnedWidth.coerceAtLeast(item.minWidth)) - stripesVisible = true - return expand(itemId) - } - - /** Takes a pinned item off its stripe and docks it back into the tree at [target]. */ - fun unpin(itemId: String, target: DockTarget = DockTarget.Root): Boolean { - val index = pinnedItems.indexOfFirst { it.item.id == itemId } - if (index < 0) return false - val pinned = pinnedItems.removeAt(index) - if (expandedBySide[pinned.side] == itemId) expandedBySide.remove(pinned.side) - open(pinned.item, target) - return true - } - - /** Expands a pinned item's panel, collapsing whatever its side had open. */ - fun expand(itemId: String): Boolean { - val pinned = pinnedItem(itemId) ?: return false - expandedBySide[pinned.side] = itemId - focusedItemId = itemId - return true - } - - /** Collapses [side] back to its bare stripe. */ - fun collapse(side: DockSide) { - val itemId = expandedBySide.remove(side) ?: return - if (focusedItemId == itemId) focusedItemId = firstItemId() - } - - /** Whether the window is actually in front of the player: docked, floating, or an open panel. */ - fun isOnScreen(itemId: String): Boolean { - val pinned = pinnedItem(itemId) ?: return contains(itemId) - return expandedBySide[pinned.side] == itemId - } - - /** - * Turns a window off, or back on, wherever it lives. - */ - fun toggleOnScreen(itemId: String): Boolean { - if (isPinned(itemId)) return togglePinned(itemId) - if (!contains(itemId)) return false - return close(itemId) - } - - /** What a click on a stripe button does: opens that panel, or closes it if it was already open. */ - fun togglePinned(itemId: String): Boolean { - val pinned = pinnedItem(itemId) ?: return false - if (expandedBySide[pinned.side] == itemId) { - collapse(pinned.side) - return true - } - return expand(itemId) - } - - /** Remembers the width the player dragged a pinned panel to. */ - fun setPinnedWidth(itemId: String, width: Float): Boolean { - val index = pinnedItems.indexOfFirst { it.item.id == itemId } - if (index < 0) return false - val pinned = pinnedItems[index] - val next = width.coerceIn(pinned.item.minWidth.coerceAtLeast(MinPinnedWidth), MaxPinnedWidth) - if (next == pinned.width) return false - pinnedItems[index] = pinned.copy(width = next) - return true - } - - /** Restores the stripes from a stored layout, dropping items the tree already holds. */ - fun applyPinned(items: List, expanded: Map, visible: Boolean = true) { - pinnedItems.clear() - pinnedItems += items.filterNot { contains(it.item.id) } - expandedBySide.clear() - expanded.forEach { (side, itemId) -> - if (pinnedItems.any { it.item.id == itemId && it.side == side }) expandedBySide[side] = itemId - } - stripesVisible = visible - } - - /** Which item each side has expanded, for storing the layout. */ - fun expandedPinned(): Map = expandedBySide.toMap() - fun dock(itemId: String, target: DockTarget): Boolean { val removal = removeItemForDock(itemId) ?: return false val node = DockNode.Stack(ids.nextStackId(), listOf(removal), removal.id) @@ -353,12 +274,39 @@ class DockingState { finishTabDrag() if (windowId != null) { val index = floatingWindows.indexOfFirst { it.id == windowId } - if (index >= 0 && floatingWindows[index].dragKey != null) { - floatingWindows[index] = floatingWindows[index].copy(dragKey = null) - } + if (index >= 0) floatingWindows[index] = floatingWindows[index].copy(dragKey = null).keptInSpace() } } + /** The dock space's size from its last layout; floating windows are kept inside it. */ + private var spaceWidth = 0f + private var spaceHeight = 0f + + /** + * Records the dock space's size and pulls back any window the change left outside it; a window + * in the hand is left alone and settled when it is dropped. + */ + internal fun updateSpaceSize(width: Float, height: Float) { + if (width == spaceWidth && height == spaceHeight) return + spaceWidth = width + spaceHeight = height + for (index in floatingWindows.indices) { + val window = floatingWindows[index] + if (window.id == draggedWindowId) continue + val kept = window.keptInSpace() + if (kept != window) floatingWindows[index] = kept + } + } + + /** Moved, never resized, to lie inside the space; a window wider than it keeps its left edge in. */ + internal fun FloatingDockWindow.keptInSpace(): FloatingDockWindow { + if (spaceWidth <= 0f || spaceHeight <= 0f) return this + return copy( + x = x.coerceIn(0f, (spaceWidth - width).coerceAtLeast(0f)), + y = y.coerceIn(0f, (spaceHeight - height).coerceAtLeast(0f)), + ) + } + fun dockDraggedWindow(target: DockTarget): Boolean { val windowId = draggedWindowId ?: return false val docked = dockWindow(windowId, target) @@ -576,7 +524,7 @@ class DockingState { return changed } - private fun removeItemForDock(itemId: String): DockItem? { + internal fun removeItemForDock(itemId: String): DockItem? { val dockedRemoval = root?.removeItem(itemId) if (dockedRemoval?.item != null) { root = dockedRemoval.root @@ -602,7 +550,7 @@ class DockingState { return item } - private fun firstItemId(): String? { + internal fun firstItemId(): String? { return root?.firstItemId() ?: floatingWindows.firstOrNull()?.stack?.items?.firstOrNull()?.id } @@ -800,3 +748,6 @@ private fun DockNode.mapStacks(transform: (DockNode.Stack) -> DockNode.Stack): D } internal fun tabNodeId(itemId: String): String = "dock-tab-$itemId" + +/** The start of the id of an open parked window's content, which the id of the window follows. */ +internal const val PinnedContentPrefix = "dock-pinned-" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorDialogs.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorDialogs.kt index f2a90ada3..f854b0273 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorDialogs.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorDialogs.kt @@ -1,11 +1,7 @@ package ru.hollowhorizon.hollowengine.client.ui.entity import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateSetOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import net.minecraft.nbt.CompoundTag import ru.hollowhorizon.hollowengine.client.slots.PlayerInventory import ru.hollowhorizon.hollowengine.client.slots.SlotGrid @@ -13,8 +9,6 @@ import ru.hollowhorizon.hollowengine.client.slots.Slots import ru.hollowhorizon.hollowengine.client.slots.ClientSlots import ru.hollowhorizon.hollowengine.client.ui.inspector.* import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTreeView -import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover import ru.hollowhorizon.hollowengine.common.attachments.editor.EntityEditorSlots import ru.hollowhorizon.hollowengine.common.ui.UiData import ru.hollowhorizon.hollowengine.common.ui.UiScope @@ -52,65 +46,6 @@ private fun EditorDialog(id: String, title: String, width: Float, onClose: () -> } } -@Composable -internal fun AssetPickerDialog(request: InspectorAssetRequest) { - val session = LocalEntityEditorSession.current - var filter by remember(request) { mutableStateOf("") } - val expanded = remember(request) { mutableStateSetOf() } - val close = { session?.pendingPicker = null } - - val rows = PathTree.rows( - paths = request.candidates, - expanded = expanded, - query = filter, - folderIcon = EntityEditorIcons.FOLDER, - fileIcon = ::assetIconFor, - selected = request.current, - ) - - EditorDialog("ee-asset-dialog", request.title, 420f, close) { - Row(tags = listOf("ee-search")) { - Image(EntityEditorIcons.SEARCH, tags = listOf("ee-search-icon")) - TextField( - value = filter, - id = "ee-asset-filter", - placeholder = EntityEditorLang.searchHint, - fontSize = 9f, - onChange = { filter = it }, - tags = listOf("insp-input", "flat"), - modifier = Modifier.grow(1f), - ) - } - - if (rows.isEmpty()) { - Text(EntityEditorLang.nothingFound, tags = listOf("insp-hint")) - return@EditorDialog - } - - UiTreeView( - items = rows, - onToggle = { item -> if (!expanded.add(item.id)) expanded.remove(item.id) }, - onSelect = { item, _ -> - val picked = item.payload - if (picked == null) { - if (!expanded.add(item.id)) expanded.remove(item.id) - } else { - request.onPick(picked) - close() - } - }, - modifier = Modifier.size(100.percent, 280.px), - tags = listOf("ee-asset-tree"), - ) - } -} - -private fun assetIconFor(path: String): String = when { - path.endsWith(".png") -> "hollowengine:textures/gui/icons/file_image.svg" - path.endsWith(".kts") -> EntityEditorIcons.SCRIPT - else -> "hollowengine:textures/gui/icons/file_model.svg" -} - @Composable internal fun InventoryDialog(session: EntityEditorSession) { val sessionId = session.slotSessionId ?: return diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorScreen.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorScreen.kt index 1311ac560..ffc7e2cc3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorScreen.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorScreen.kt @@ -52,7 +52,7 @@ internal class EntityEditorScreen( EntitySidebar(session, sidebarWidth) } - session.pendingPicker?.let { picker -> AssetPickerDialog(picker) } + session.pendingPicker?.let { picker -> AssetPickerDialog(picker) { session.pendingPicker = null } } if (session.slotSessionId != null) InventoryDialog(session) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorSidebar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorSidebar.kt index 931bfe04c..aa44155fa 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorSidebar.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorSidebar.kt @@ -86,7 +86,7 @@ private fun SidebarSearch(session: EntityEditorSession) { fontSize = 9f, onChange = { session.query = it }, tags = listOf("insp-input", "flat"), - modifier = Modifier.grow(1f), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f), ) if (session.query.isNotEmpty()) { InspectorIconButton(EntityEditorIcons.CLOSE, EntityEditorLang.search) { session.query = "" } 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 new file mode 100644 index 000000000..e358d5f0d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphCanvas.kt @@ -0,0 +1,373 @@ +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.ui.Box +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.UiAlign +import ru.hollowhorizon.hollowengine.client.ui.UiBoxMode +import ru.hollowhorizon.hollowengine.client.ui.UiCanvasDrawScope +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.UiDrawStyle +import ru.hollowhorizon.hollowengine.client.ui.UiEvent +import ru.hollowhorizon.hollowengine.client.ui.alignItems +import ru.hollowhorizon.hollowengine.client.ui.background +import ru.hollowhorizon.hollowengine.client.ui.clip +import ru.hollowhorizon.hollowengine.client.ui.drawBehind +import ru.hollowhorizon.hollowengine.client.ui.focus +import ru.hollowhorizon.hollowengine.client.ui.input +import ru.hollowhorizon.hollowengine.client.ui.inputTransparent +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.onClick +import ru.hollowhorizon.hollowengine.client.ui.onDrag +import ru.hollowhorizon.hollowengine.client.ui.onExit +import ru.hollowhorizon.hollowengine.client.ui.onHover +import ru.hollowhorizon.hollowengine.client.ui.onKeyInput +import ru.hollowhorizon.hollowengine.client.ui.onPlaced +import ru.hollowhorizon.hollowengine.client.ui.onPress +import ru.hollowhorizon.hollowengine.client.ui.onRelease +import ru.hollowhorizon.hollowengine.client.ui.onScroll +import ru.hollowhorizon.hollowengine.client.ui.padding +import ru.hollowhorizon.hollowengine.client.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.pivot +import ru.hollowhorizon.hollowengine.client.ui.position +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.scale +import ru.hollowhorizon.hollowengine.client.ui.shape.GenericShape +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.style +import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint +import ru.hollowhorizon.hollowengine.client.ui.transition +import ru.hollowhorizon.hollowengine.client.ui.translate +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +const val GraphStylesheet = "hollowengine:ui/styles/graph.hss" + +/** The colors the canvas draws itself; everything built out of nodes takes its look from [GraphStylesheet]. */ +object GraphColors { + val Grid = UiColor(1f, 1f, 1f, 0.04f) + val Edge = UiColor(0.60f, 0.64f, 0.72f) + val EdgeHover = UiColor(0.82f, 0.86f, 0.94f) + val EdgeSelected = UiColor(0.37f, 0.62f, 0.98f) + val Link = UiColor(0.37f, 0.62f, 0.98f) +} + +/** Where the pointer is, in every space a handler of the graph may want it in. */ +data class GraphPointer( + val screenX: Float, + val screenY: Float, + val canvasX: Float, + val canvasY: Float, + val graphX: Float, + val graphY: Float, + val modifiers: Int = 0, +) { + val control: Boolean get() = modifiers and GLFW.GLFW_MOD_CONTROL != 0 + val shift: Boolean get() = modifiers and GLFW.GLFW_MOD_SHIFT != 0 +} + +/** A link of the graph as the canvas draws it under the nodes. [key] is what a click on it reports. */ +class GraphEdge( + val key: Any, + val curve: GraphCurve, + val color: UiColor = GraphColors.Edge, + val arrow: Boolean = false, + val selected: Boolean = false, +) + +/** + * A link being dragged out and not yet dropped, in canvas space: a straight line, or [curve] when the + * owner shapes it like the links it draws. + */ +data class GraphLinkPreview( + val fromX: Float, + val fromY: Float, + val toX: Float, + val toY: Float, + val color: UiColor = GraphColors.Link, + val curve: GraphCurve? = null, +) + +/** A node as the minimap marks it, in graph space. */ +data class GraphMiniMapItem( + val rect: GraphRect, + val color: UiColor, + val tooltip: String? = null, +) + +/** + * A pannable, zoomable canvas for a node graph: a grid, the links between nodes, and the nodes. + */ +@Composable +fun GraphCanvas( + id: String, + view: GraphViewState, + modifier: Modifier = Modifier, + edges: List = emptyList(), + link: GraphLinkPreview? = null, + minimap: List = emptyList(), + onBackgroundClick: (GraphPointer) -> Unit = {}, + onEdgeClick: (Any) -> Unit = {}, + onContextMenu: (GraphPointer, edge: Any?) -> Unit = { _, _ -> }, + onRelease: () -> Unit = {}, + onSelectArea: ((area: GraphRect, modifiers: Int) -> Unit)? = null, + onKey: (UiKeyInput) -> Boolean = { false }, + overlay: @Composable () -> Unit = {}, + nodes: @Composable () -> Unit, +) { + var canvas by remember { mutableStateOf(UiRect.Zero) } + var hovered by remember { mutableStateOf(null) } + var marquee by remember { mutableStateOf(null) } + val grabbedAt = remember { floatArrayOf(0f, 0f) } + val selecting = remember { booleanArrayOf(false) } + + LaunchedEffect(view) { + while (true) { + withFrameNanos(view::advance) + } + } + + fun pointer(event: UiEvent) = GraphPointer( + screenX = event.x, + screenY = event.y, + canvasX = event.localX, + canvasY = event.localY, + graphX = view.toGraphX(event.localX), + graphY = view.toGraphY(event.localY), + modifiers = event.modifiers, + ) + + fun edgeAt(x: Float, y: Float): Any? = + GraphCurves.nearest(edges.map { it.key to it.curve }, x, y, EDGE_HIT_RADIUS) + + Box( + id = id, + mode = UiBoxMode.STACK, + tags = listOf("graph-canvas"), + modifier = modifier + .style(GraphStylesheet) + .clip(true) + .onPlaced { canvas = it } + .input(hoverable = true, clickable = true, draggable = true) + .drawBehind(GraphDrawKey(view.zoom, view.panX, view.panY, edges, link, hovered)) { + drawGrid(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 + } + .onExit { hovered = null } + .focus() + .onKeyInput { input -> + when { + onKey(input) -> input.consume() + input.key == GLFW.GLFW_KEY_F -> { + view.reset() + input.consume() + } + } + } + .onScroll { event -> + view.zoomBy(if (event.rawScrollY > 0f) ZOOM_STEP else 1f / ZOOM_STEP, event.localX, event.localY) + event.consume() + } + .onPress { event -> + grabbedAt[0] = event.localX + grabbedAt[1] = event.localY + selecting[0] = onSelectArea != null && event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT + if (!selecting[0]) view.grab(event.localX, event.localY) + event.consume() + } + .onDrag { event -> + val x = grabbedAt[0] + event.dragTotalX + val y = grabbedAt[1] + event.dragTotalY + if (selecting[0]) { + marquee = GraphRect.between(grabbedAt[0], grabbedAt[1], x, y) + } else { + view.dragTo(x, y) + } + event.consume() + } + .onRelease { event -> + marquee?.let { area -> + marquee = null + val covered = GraphRect.between( + view.toGraphX(area.x), view.toGraphY(area.y), + view.toGraphX(area.x + area.width), view.toGraphY(area.y + area.height), + ) + onSelectArea?.invoke(covered, event.modifiers) + } + selecting[0] = false + onRelease() + event.consume() + } + .onClick { event -> + val edge = edgeAt(event.localX, event.localY) + when { + event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT -> onContextMenu(pointer(event), edge) + edge != null -> onEdgeClick(edge) + else -> onBackgroundClick(pointer(event)) + } + event.consume() + }, + ) { + Box( + mode = UiBoxMode.STACK, + modifier = Modifier + .position(0.px, 0.px) + .size(100.percent, 100.percent) + .pivot(0.px, 0.px) + .translate(view.panX, view.panY) + .scale(view.zoom) + .transition(), + ) { + nodes() + } + + marquee?.let { area -> + Box( + tags = listOf("graph-marquee"), + modifier = Modifier.position(area.x.px, area.y.px).size(area.width.px, area.height.px).inputTransparent(), + ) + } + + if (minimap.isNotEmpty()) { + Column( + modifier = Modifier.size(100.percent, 100.percent).padding(8.px).inputTransparent() + .alignItems(horizontal = UiAlign.END, vertical = UiAlign.END), + ) { + GraphMiniMap(minimap) { x, y -> view.centerOn(x, y, canvas.width, canvas.height) } + } + } + overlay() + } +} + +/** + * Every node of the graph at a glance; a click on one brings it to the middle of the canvas. + */ +@Composable +private fun GraphMiniMap(items: List, onJumpTo: (Float, Float) -> Unit) { + val minX = items.minOf { it.rect.x } + val maxX = items.maxOf { it.rect.x + it.rect.width } + val minY = items.minOf { it.rect.y } + val maxY = items.maxOf { it.rect.y + it.rect.height } + val scale = minOf( + (MINIMAP_WIDTH - 8f) / (maxX - minX).coerceAtLeast(1f), + (MINIMAP_HEIGHT - 8f) / (maxY - minY).coerceAtLeast(1f), + ) + + Box( + mode = UiBoxMode.STACK, + tags = listOf("graph-minimap"), + modifier = Modifier.size(MINIMAP_WIDTH.px, MINIMAP_HEIGHT.px), + ) { + items.forEachIndexed { index, item -> + Box( + id = "graph-minimap-$index", + tags = listOf("graph-minimap-mark"), + modifier = Modifier + .position((4f + (item.rect.x - minX) * scale).px, (4f + (item.rect.y - minY) * scale).px) + .size((item.rect.width * scale).coerceAtLeast(4f).px, (item.rect.height * scale).coerceAtLeast(3f).px) + .background(item.color) + .input(hoverable = true, clickable = true) + .let { if (item.tooltip != null) it.tooltipOnHover(item.tooltip) else it } + .onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onJumpTo(item.rect.centerX, item.rect.centerY) + event.consume() + }, + ) + } + } +} + +/** A line of the grid every [GRID_STEP] graph units, while they are far enough apart to be worth it. */ +private fun UiCanvasDrawScope.drawGrid(view: GraphViewState) { + val paint = UiPaint.Color(GraphColors.Grid) + val step = GRID_STEP * view.zoom + if (step < 4f) return + + var x = view.panX.mod(step) + while (x < size.width) { + drawRect(UiRect(x, 0f, 1f, size.height), paint) + x += step + } + var y = view.panY.mod(step) + while (y < size.height) { + drawRect(UiRect(0f, y, size.width, 1f), paint) + y += step + } +} + +/** Draws a curve segment by segment, each clipped to the canvas, and an arrowhead at its end. */ +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 + + 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)) +} + +private data class GraphDrawKey( + val zoom: Float, + val panX: Float, + val panY: Float, + val edges: List, + val link: GraphLinkPreview?, + val hovered: Any?, +) + +/** How far apart the lines of the grid are, in graph units. */ +const val GRID_STEP = 32f + +/** How big an arrowhead is at zoom 1; a link that ends in one stops this short of its node. */ +const val ARROW_SIZE = 9f + +private const val ARROW_SPREAD = 0.5f +private const val EDGE_HIT_RADIUS = 6f +private const val ZOOM_STEP = 1.18f +private const val MINIMAP_WIDTH = 120f +private const val MINIMAP_HEIGHT = 78f 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 new file mode 100644 index 000000000..fff987b06 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphGeometry.kt @@ -0,0 +1,161 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import kotlin.math.abs +import kotlin.math.hypot + +/** 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) { + val centerX get() = x + width / 2f + val centerY get() = y + height / 2f + + fun contains(px: Float, py: Float): Boolean = px >= x && px <= x + width && py >= y && py <= y + height + + fun intersects(other: GraphRect): Boolean = + x < other.x + other.width && other.x < x + width && y < other.y + other.height && other.y < y + height + + /** This rectangle of the graph as it lies on the canvas. */ + fun toCanvas(view: GraphViewState) = + GraphRect(view.toCanvasX(x), view.toCanvasY(y), width * view.zoom, height * view.zoom) + + companion object { + /** The rectangle two corners span, in whichever order they came. */ + fun between(x1: Float, y1: Float, x2: Float, y2: Float) = + GraphRect(minOf(x1, x2), minOf(y1, y2), abs(x2 - x1), abs(y2 - y1)) + } +} + +/** A connection as a sampled curve in canvas space: what gets drawn, clicked and labeled. */ +class GraphCurve(val points: FloatArray) { + val pointCount: Int get() = points.size / 2 + + /** A point [fraction] of the way along the curve. */ + fun pointAt(fraction: Float): FloatArray { + val last = pointCount - 1 + val step = (fraction.coerceIn(0f, 1f) * last).toInt().coerceIn(0, last) + return floatArrayOf(points[step * 2], points[step * 2 + 1]) + } + + fun distanceTo(x: Float, y: Float): Float = (0 until pointCount - 1).minOfOrNull { index -> + distanceToSegment( + x, y, + points[index * 2], points[index * 2 + 1], + points[index * 2 + 2], points[index * 2 + 3], + ) + } ?: Float.MAX_VALUE + + override fun equals(other: Any?) = other is GraphCurve && points.contentEquals(other.points) + override fun hashCode() = points.contentHashCode() +} + +/** 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 + + /** + * A link from a side of [from] into a side of [to]. + */ + fun betweenBoxes(from: GraphRect, to: GraphRect, offset: Float = 0f, gap: Float = 0f): GraphCurve { + val horizontal = abs(to.centerX - from.centerX) >= abs(to.centerY - from.centerY) + val start = from.sidePoint(towards = to, horizontal = horizontal, along = offset, gap = 0f) + val end = to.sidePoint(towards = from, horizontal = horizontal, along = offset, gap = gap) + + val reach = if (horizontal) abs(end[0] - start[0]) else abs(end[1] - start[1]) + val bow = (reach * 0.6f).coerceIn(MIN_BOW, MAX_BOW) + return bezier( + start[0], start[1], + start[0] + if (horizontal) bow * start[2] else 0f, start[1] + if (horizontal) 0f else bow * start[3], + end[0] + if (horizontal) bow * end[2] else 0f, end[1] + if (horizontal) 0f else bow * end[3], + end[0], end[1], + ) + } + + /** + * A link from an output pin to an input pin. + */ + fun betweenPins(fromX: Float, fromY: Float, toX: Float, toY: Float, zoom: Float): GraphCurve { + val bow = (abs(toX - fromX) * 0.5f).coerceIn(MIN_BOW * zoom, MAX_BOW * zoom) + return bezier(fromX, fromY, fromX + bow, fromY, toX - bow, toY, toX, toY) + } + + /** 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 } + .minByOrNull { it.second }?.first + + private fun bezier( + x0: Float, y0: Float, + x1: Float, y1: Float, + x2: Float, y2: Float, + x3: Float, y3: Float, + ): GraphCurve { + val points = FloatArray((SAMPLES + 1) * 2) + for (step in 0..SAMPLES) { + val t = step / SAMPLES.toFloat() + val inverse = 1f - t + val a = inverse * inverse * inverse + val b = 3f * inverse * inverse * t + val c = 3f * inverse * t * t + val d = t * t * t + points[step * 2] = a * x0 + b * x1 + c * x2 + d * x3 + points[step * 2 + 1] = a * y0 + b * y1 + c * y2 + d * y3 + } + return GraphCurve(points) + } + + /** Where a link attaches to this box: x, y, then the direction it leaves in. */ + private fun GraphRect.sidePoint(towards: GraphRect, horizontal: Boolean, along: Float, gap: Float): FloatArray = + if (horizontal) { + val right = towards.centerX >= centerX + floatArrayOf( + if (right) x + width + gap else x - gap, + (centerY + along).coerceIn(y + 4f, y + height - 4f), + if (right) 1f else -1f, + 0f, + ) + } else { + val below = towards.centerY >= centerY + floatArrayOf( + (centerX + along).coerceIn(x + 4f, x + width - 4f), + if (below) y + height + gap else y - gap, + 0f, + if (below) 1f else -1f, + ) + } +} + +internal fun distanceToSegment(x: Float, y: Float, x1: Float, y1: Float, x2: Float, y2: Float): Float { + val dx = x2 - x1 + val dy = y2 - y1 + val lengthSquared = dx * dx + dy * dy + if (lengthSquared == 0f) return hypot(x - x1, y - y1) + 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/GraphNode.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphNode.kt new file mode 100644 index 000000000..31facb13c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphNode.kt @@ -0,0 +1,99 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.HollowUiContent +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.UiEvent +import ru.hollowhorizon.hollowengine.client.ui.UiLength +import ru.hollowhorizon.hollowengine.client.ui.input +import ru.hollowhorizon.hollowengine.client.ui.onDrag +import ru.hollowhorizon.hollowengine.client.ui.onPress +import ru.hollowhorizon.hollowengine.client.ui.onRelease +import ru.hollowhorizon.hollowengine.client.ui.position +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.size + +/** + * A press, drag or release on a node: how far the pointer has gone since the press, in graph units, + * and where it is on the canvas, for handlers that follow it, such as a link being drawn out. + */ +data class GraphNodeGesture( + val graphDeltaX: Float, + val graphDeltaY: Float, + val canvasX: Float, + val canvasY: Float, + val modifiers: Int, +) { + val control: Boolean get() = modifiers and GLFW.GLFW_MOD_CONTROL != 0 + val shift: Boolean get() = modifiers and GLFW.GLFW_MOD_SHIFT != 0 +} + +/** + * One node of a graph, placed at ([x], [y]) in graph units inside [GraphCanvas]. Its look comes from + * the `graph-node` tag and whatever [tags] the owner adds; `selected` is added for [selected]. + */ +@Composable +fun GraphNode( + id: String, + canvasId: String, + view: GraphViewState, + x: Float, + y: Float, + width: Float, + height: Float? = null, + selected: Boolean = false, + tags: List = emptyList(), + onPress: (GraphNodeGesture) -> Unit = {}, + onDrag: (GraphNodeGesture) -> Unit = {}, + onRelease: () -> Unit = {}, + onContextMenu: (screenX: Float, screenY: Float) -> Unit = { _, _ -> }, + content: HollowUiContent, +) { + fun gesture(event: UiEvent): GraphNodeGesture { + val canvas = event.ancestorLocalPositions[canvasId] + return GraphNodeGesture( + graphDeltaX = event.dragTotalX / view.zoom, + graphDeltaY = event.dragTotalY / view.zoom, + canvasX = canvas?.x ?: 0f, + canvasY = canvas?.y ?: 0f, + modifiers = event.modifiers, + ) + } + + val held = remember { booleanArrayOf(false) } + Column( + id = id, + tags = listOf("graph-node") + tags + if (selected) listOf("selected") else emptyList(), + modifier = Modifier + .position(x.px, y.px) + .size(width.px, height?.px ?: UiLength.Auto) + .input(hoverable = true, clickable = true, draggable = true) + .onPress { event -> + when (event.button) { + GLFW.GLFW_MOUSE_BUTTON_RIGHT -> onContextMenu(event.x, event.y) + GLFW.GLFW_MOUSE_BUTTON_LEFT -> { + held[0] = true + onPress(gesture(event)) + } + + else -> return@onPress + } + event.consume() + } + .onDrag { event -> + if (!held[0] || event.button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return@onDrag + onDrag(gesture(event)) + event.consume() + } + .onRelease { event -> + if (!held[0]) return@onRelease + held[0] = false + onRelease() + event.consume() + }, + content = content, + ) +} 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 new file mode 100644 index 000000000..b956785cd --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewState.kt @@ -0,0 +1,118 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlin.math.abs + +/** + * Pan and zoom of one graph: a point of the graph lands on the canvas at `point * zoom + pan`. + * + * Zoom follows a spring toward where the wheel asked it to be, anchored at the point under the cursor, + * so that point stays put while the rest grows around it. It runs off [advance], once a frame. + * + * A pan grabs the graph point under the pointer and keeps it there; that point becomes the anchor, so + * a zoom still easing in keeps easing around the hand rather than fighting it. + */ +class GraphViewState( + private val homeX: Float = 60f, + private val homeY: Float = 60f, + private val minZoom: Float = 0.3f, + private val maxZoom: Float = 2.5f, +) { + var panX by mutableStateOf(homeX) + var panY by mutableStateOf(homeY) + var zoom by mutableStateOf(1f) + private set + + private var targetZoom = 1f + private var velocity = 0f + private var anchorCanvasX = 0f + private var anchorCanvasY = 0f + private var anchorGraphX = 0f + private var anchorGraphY = 0f + private var anchored = false + private var lastFrame = 0L + + fun toCanvasX(x: Float) = x * zoom + panX + fun toCanvasY(y: Float) = y * zoom + panY + fun toGraphX(x: Float) = (x - panX) / zoom + fun toGraphY(y: Float) = (y - panY) / zoom + + /** 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 + anchorCanvasY = aroundY + anchorGraphX = toGraphX(aroundX) + anchorGraphY = toGraphY(aroundY) + anchored = true + targetZoom = (targetZoom * step).coerceIn(minZoom, maxZoom) + } + + /** Takes hold of the graph point under ([canvasX], [canvasY]); [dragTo] then moves it. */ + fun grab(canvasX: Float, canvasY: Float) { + anchorGraphX = toGraphX(canvasX) + anchorGraphY = toGraphY(canvasY) + anchorCanvasX = canvasX + anchorCanvasY = canvasY + anchored = true + } + + /** Puts the grabbed point under ([canvasX], [canvasY]) at whatever zoom the view is at right now. */ + fun dragTo(canvasX: Float, canvasY: Float) { + anchorCanvasX = canvasX + anchorCanvasY = canvasY + reanchor() + } + + /** Back to where a new view starts. */ + fun reset() { + targetZoom = 1f + velocity = 0f + anchored = false + panX = homeX + panY = homeY + zoom = 1f + } + + /** Puts the graph point ([x], [y]) in the middle of a canvas [width] by [height]. */ + fun centerOn(x: Float, y: Float, width: Float, height: Float) { + anchored = false + panX = width / 2f - x * zoom + panY = height / 2f - y * zoom + } + + /** One step of the spring; returns whether anything is still moving. */ + fun advance(frameNanos: Long): Boolean { + val previous = lastFrame + lastFrame = frameNanos + if (previous == 0L) return false + + val delta = targetZoom - zoom + if (abs(delta) < 0.0005f && abs(velocity) < 0.0005f) { + if (zoom != targetZoom) { + zoom = targetZoom + reanchor() + } + velocity = 0f + return false + } + + val dt = ((frameNanos - previous) / 1_000_000_000f).coerceIn(0f, 0.05f) + velocity += (delta * STIFFNESS - velocity * DAMPING) * dt + zoom += velocity * dt + reanchor() + return true + } + + private fun reanchor() { + if (!anchored) return + panX = anchorCanvasX - anchorGraphX * zoom + panY = anchorCanvasY - anchorGraphY * zoom + } + + private companion object { + const val STIFFNESS = 260f + const val DAMPING = 30f + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeBuiltinFileTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeBuiltinFileTypes.kt index d722dba75..116034924 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeBuiltinFileTypes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeBuiltinFileTypes.kt @@ -3,7 +3,9 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeAnimatorDocument import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeImageDocument import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeRigDocument +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeSoundsDocument +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument internal const val SoundsFileName = "sounds.json" @@ -14,8 +16,28 @@ internal fun HollowIdeFileTypeRegistry.registerBuiltinFileTypes( soundsEditor: HollowIdeFileEditor, animatorEditor: HollowIdeFileEditor, rigEditor: HollowIdeFileEditor, + vfxEditor: HollowIdeFileEditor, + shaderGraphEditor: HollowIdeFileEditor, textEditor: HollowIdeFileEditor, ) { + register( + HollowIdeFileType.extensions( + id = "material", + extensions = listOf(".material"), + priority = 285, + loader = { _, bytes -> HollowIdeShaderGraphDocument(bytes) }, + editor = shaderGraphEditor, + ), + ) + register( + HollowIdeFileType.extensions( + id = "vfx", + extensions = listOf(".vfx"), + priority = 280, + loader = { _, bytes -> HollowIdeVfxDocument(bytes) }, + editor = vfxEditor, + ), + ) register( HollowIdeFileType.extensions( id = "rig", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeDiagnosticsPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeDiagnosticsPanel.kt deleted file mode 100644 index f07710a1b..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeDiagnosticsPanel.kt +++ /dev/null @@ -1,121 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import net.minecraft.client.Minecraft -import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnosticSeverity -import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover -import ru.hollowhorizon.hollowengine.generated.Assets - -@Composable -internal fun HollowIdeDiagnosticsBadge( - fileId: String, - diagnostics: List, - onToggle: (String) -> Unit, -) { - if (diagnostics.isEmpty()) return - val errors = diagnostics.count { it.severity == UiTextDiagnosticSeverity.ERROR } - val warnings = diagnostics.count { it.severity == UiTextDiagnosticSeverity.WARNING } - Row( - tags = listOf("ide-diagnostics-badge", if (errors > 0) "has-errors" else "has-warnings"), - modifier = Modifier.align(UiAlign.END, UiAlign.START) - .input(clickable = true, hoverable = true) - .onClick { event -> - onToggle(fileId) - event.consume() - } - ) { - if (errors > 0) { - Image(Assets.Hollowengine.Textures.Gui.Icons.ERROR.toString(), modifier = Modifier.size(10.px, 10.px)) - Text(errors.toString(), tags = listOf("ide-diagnostics-text")) - } - if (warnings > 0) { - Image(Assets.Hollowengine.Textures.Gui.Icons.WARN.toString(), modifier = Modifier.size(10.px, 10.px)) - Text(warnings.toString(), tags = listOf("ide-diagnostics-text")) - } - } -} - -@Composable -internal fun HollowIdeDiagnosticsPanel( - fileId: String, - diagnostics: List, - height: Float, - onResize: (String, Float) -> Unit, -) { - val dragStartHeight = remember(fileId) { floatArrayOf(height) } - Column(tags = listOf("ide-diagnostics-wrap"), modifier = Modifier.size(100.percent, height.px)) { - Box( - tags = listOf("ide-diagnostics-resizer"), - modifier = Modifier.input(clickable = true, hoverable = true) - .cursor(UiCursorShape.RESIZE_VERTICAL) - .onPress { dragStartHeight[0] = height } - .onDrag { event -> - onResize(fileId, dragStartHeight[0] - event.dragTotalY) - event.consume() - } - ) - Row( - tags = listOf("ide-diagnostics-header"), - modifier = Modifier.alignItems(vertical = UiAlign.CENTER), - ) { - Text(diagnosticsSummary(diagnostics), tags = listOf("ide-diagnostics-title")) - Box(modifier = Modifier.size(0.px, 100.percent).grow(1f)) - CopyButton("Copy all") { diagnostics.joinToString("\n", transform = UiTextDiagnostic::clipboardLine) } - } - Column( - tags = listOf("ide-diagnostics-panel"), - modifier = Modifier.scrollable(), - ) { - diagnostics.forEach { diagnostic -> - Row( - tags = listOf("ide-diagnostic-row", diagnostic.severity.name.lowercase()), - modifier = Modifier.alignItems(vertical = UiAlign.CENTER), - ) { - Text(diagnostic.severity.name, tags = listOf("ide-diagnostic-severity")) - Text(diagnostic.locationLabel(), tags = listOf("ide-diagnostic-location")) - Text(diagnostic.message, tags = listOf("ide-diagnostic-message")) - CopyButton("Copy this message") { diagnostic.clipboardLine() } - } - } - } - } -} - -@Composable -private fun CopyButton(tooltip: String, text: () -> String) { - Box( - tags = listOf("ide-diagnostic-copy"), - modifier = Modifier.input(clickable = true, hoverable = true) - .cursor(UiCursorShape.HAND) - .onClick { event -> - Minecraft.getInstance().keyboardHandler.clipboard = text() - event.consume() - } - .tooltipOnHover(tooltip), - ) { - Image( - Assets.Hollowengine.Textures.Gui.Icons.COPY.toString(), - tags = listOf("ide-diagnostic-copy-icon"), - modifier = Modifier.align(UiAlign.CENTER, UiAlign.CENTER), - ) - } -} - -private fun diagnosticsSummary(diagnostics: List): String { - val errors = diagnostics.count { it.severity == UiTextDiagnosticSeverity.ERROR } - val warnings = diagnostics.count { it.severity == UiTextDiagnosticSeverity.WARNING } - return buildList { - if (errors > 0) add("$errors error${if (errors == 1) "" else "s"}") - if (warnings > 0) add("$warnings warning${if (warnings == 1) "" else "s"}") - if (isEmpty()) add("${diagnostics.size} message${if (diagnostics.size == 1) "" else "s"}") - }.joinToString(", ") -} - -private fun UiTextDiagnostic.locationLabel(): String { - return if (line > 0 && column > 0) "$line:$column" else "-" -} - -private fun UiTextDiagnostic.clipboardLine(): String = "${locationLabel()} ${severity.name}: $message" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt index 3ec4a0dfa..c3349d507 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt @@ -137,6 +137,6 @@ private fun HollowIdeFileAction.withSeparator() = HollowIdeFileAction( private const val SaveIcon = "hollowengine:textures/gui/icons/save.svg" private const val ReformatIcon = "hollowengine:textures/gui/icons/code_editor.svg" -private const val CloseIcon = "hollowengine:textures/gui/icons/close.png" +private const val CloseIcon = "hollowengine:textures/gui/icons/cross.svg" private const val CopyIcon = "hollowengine:textures/gui/icons/copy.svg" private const val FolderIcon = "hollowengine:textures/gui/icons/folder_open.svg" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGameViewport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGameViewport.kt index cef8ec4e1..22d80edca 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGameViewport.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeGameViewport.kt @@ -1,10 +1,9 @@ package ru.hollowhorizon.hollowengine.client.ui.ide +import com.mojang.blaze3d.systems.RenderSystem import net.minecraft.client.Minecraft import org.lwjgl.glfw.GLFW -import org.lwjgl.opengl.GL11 import ru.hollowhorizon.hollowengine.bootstrap.runtime.RuntimeBridge -import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeGameViewport.rendering import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import kotlin.math.ceil import kotlin.math.min @@ -14,14 +13,16 @@ import kotlin.math.roundToInt * Where the game panel sits, and how large the game renders for it. */ internal object HollowIdeGameViewport { + private class PanelReport(val owner: Any, val bounds: UiRect) + @Volatile - private var panelBounds: UiRect? = null + private var panel: PanelReport? = null @Volatile private var windowMetrics: RuntimeBridge.GameViewportMetrics? = null @Volatile - private var rendering = false + private var windowPass = false @Volatile private var windowWidth = 0 @@ -29,24 +30,30 @@ internal object HollowIdeGameViewport { @Volatile private var windowHeight = 0 - /** Called by the panel on every layout pass, and with null once it leaves the screen. */ - fun report(bounds: UiRect?) { - panelBounds = bounds + /** Called by a docked panel with its bounds; [owner] tells an old panel instance from its replacement. */ + fun report(owner: Any, bounds: UiRect) { + panel = PanelReport(owner, bounds) + } + + /** Withdraws [owner]'s bounds, unless another panel instance has reported since. */ + fun release(owner: Any) { + if (panel?.owner === owner) panel = null } /** Whether a docked panel is showing the game, which is the only case the game is cut down for. */ - fun isEmbedded(): Boolean = panelBounds != null + fun isEmbedded(): Boolean = panel != null fun windowWidth(): Int = windowWidth.takeIf { it > 0 } ?: Minecraft.getInstance().window.width fun windowHeight(): Int = windowHeight.takeIf { it > 0 } ?: Minecraft.getInstance().window.height /** - * Makes the main game target match the physical pixels the panel occupies. The window getters - * are answered with the panel's size only while [rendering], while the GUI dimensions stay - * virtual until the panel disappears, so screen layout and input agree on one coordinate space. + * Makes the main game target match the physical pixels the panel occupies. Runs before anything + * else in the frame, so tasks, ticks and input see the same framebuffer size as rendering does: + * whatever vanilla sizes from the window outside rendering (post chains rebuilt by a resource + * reload, the transparency chain, entity effects) is sized for the panel too. */ - fun beginRender(minecraft: Minecraft) { + fun beginFrame(minecraft: Minecraft) { sampleWindowSize(minecraft) val requested = requestedMetrics(minecraft) if (requested == null) { @@ -62,36 +69,32 @@ internal object HollowIdeGameViewport { target.resize(requested.framebufferWidth(), requested.framebufferHeight(), Minecraft.ON_OSX) minecraft.gameRenderer.resize(requested.framebufferWidth(), requested.framebufferHeight()) } - target.bindWrite(true) if (metricsChanged || resized) { minecraft.screen?.resize(minecraft, requested.guiScaledWidth(), requested.guiScaledHeight()) } - rendering = true } - fun endRender() { - rendering = false + /** The finished frame goes onto the real window, and the editor is drawn over it at the window's size. */ + fun beginWindowPass() { + windowPass = true } /** - * After game has been blitted, the screen belongs to the editor again: the game left the - * viewport sized for the panel, and everything drawn from here on covers the whole window. + * After game has been blitted, the screen belongs to the editor: the game left the viewport + * sized for the panel, and everything drawn from here on covers the whole window. */ fun restoreWindowViewport() { if (windowMetrics == null) return - GL11.glViewport(0, 0, windowWidth(), windowHeight()) + RenderSystem.viewport(0, 0, windowWidth(), windowHeight()) } - /** Vanilla has already restored its real target during resize; recompute virtual metrics next frame. */ - fun invalidateWindowMetrics() { - rendering = false - windowMetrics = null - sampleWindowSize(Minecraft.getInstance()) + fun endWindowPass() { + windowPass = false } fun metrics(): RuntimeBridge.GameViewportMetrics? = windowMetrics - fun isRendering(): Boolean = rendering + fun isWindowPass(): Boolean = windowPass private fun sampleWindowSize(minecraft: Minecraft) { val width = IntArray(1) @@ -105,7 +108,7 @@ internal object HollowIdeGameViewport { private fun requestedMetrics(minecraft: Minecraft): RuntimeBridge.GameViewportMetrics? { if (!HollowIdeOverlay.expanded) return null - val bounds = panelBounds ?: return null + val bounds = panel?.bounds ?: return null if (bounds.width <= 0f || bounds.height <= 0f) return null val framebufferScale = HollowIdeScale.factor() @@ -131,23 +134,20 @@ internal object HollowIdeGameViewport { return scale.toDouble() } + /** + * Metrics are only ever dropped here, so the target, the post chains and the screen layout + * always return to the window together with the window getters. + */ private fun restoreWindowTarget(minecraft: Minecraft) { - if (windowMetrics == null) { - rendering = false - return - } - rendering = false + if (windowMetrics == null) return windowMetrics = null - val width = windowWidth().coerceAtLeast(1) - val height = windowHeight().coerceAtLeast(1) + val window = minecraft.window val target = minecraft.mainRenderTarget - if (target.width != width || target.height != height) { - target.resize(width, height, Minecraft.ON_OSX) - minecraft.gameRenderer.resize(width, height) + if (target.width != window.width || target.height != window.height) { + target.resize(window.width, window.height, Minecraft.ON_OSX) + minecraft.gameRenderer.resize(window.width, window.height) } - val window = minecraft.window minecraft.screen?.resize(minecraft, window.guiScaledWidth, window.guiScaledHeight) - target.bindWrite(true) } fun imageRect(bounds: UiRect): UiRect? { @@ -165,5 +165,5 @@ internal object HollowIdeGameViewport { ) } - fun imageRect(): UiRect? = panelBounds?.let(::imageRect) + fun imageRect(): UiRect? = panel?.bounds?.let(::imageRect) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeLayoutStore.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeLayoutStore.kt index c9f9f741a..771b4ff16 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeLayoutStore.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeLayoutStore.kt @@ -3,13 +3,18 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.apache.logging.log4j.LogManager +import ru.hollowhorizon.hollowengine.client.ui.docking.DefaultBottomHeight import ru.hollowhorizon.hollowengine.client.ui.docking.DefaultPinnedWidth +import ru.hollowhorizon.hollowengine.client.ui.docking.DockAnchor import ru.hollowhorizon.hollowengine.client.ui.docking.DockItem import ru.hollowhorizon.hollowengine.client.ui.docking.DockNode import ru.hollowhorizon.hollowengine.client.ui.docking.DockOrientation import ru.hollowhorizon.hollowengine.client.ui.docking.DockPinnedItem import ru.hollowhorizon.hollowengine.client.ui.docking.DockSide +import ru.hollowhorizon.hollowengine.client.ui.docking.DockStripeGroup import ru.hollowhorizon.hollowengine.client.ui.docking.DockingState +import ru.hollowhorizon.hollowengine.client.ui.docking.applyPinned +import ru.hollowhorizon.hollowengine.client.ui.docking.expandedPinned import ru.hollowhorizon.hollowengine.common.config.Config import java.util.* import kotlin.io.path.createParentDirectories @@ -44,6 +49,7 @@ internal data class StoredPinnedItem( val id: String, val side: DockSide = DockSide.LEFT, val width: Float = DefaultPinnedWidth, + val group: DockStripeGroup = DockStripeGroup.TOP, val expanded: Boolean = false, ) @@ -55,6 +61,9 @@ internal data class StoredDockLayout( val focused: String? = null, val pinned: List = emptyList(), val stripesVisible: Boolean = true, + val bottomHeight: Float = DefaultBottomHeight, + val bottomFraction: Float = 0.5f, + val sideSplit: Map = emptyMap(), ) /** @@ -104,10 +113,14 @@ internal fun DockingState.capture(): StoredDockLayout { id = pinned.item.id, side = pinned.side, width = pinned.width, - expanded = expanded[pinned.side] == pinned.item.id, + expanded = expanded[pinned.anchor] == pinned.item.id, + group = pinned.group, ) }, stripesVisible = stripesVisible, + bottomHeight = bottomHeight, + bottomFraction = bottomFraction, + sideSplit = sideSplitFractions.toMap(), ) } @@ -129,14 +142,17 @@ internal fun DockingState.restore(layout: StoredDockLayout, resolve: (String) -> } val pinned = layout.pinned.mapNotNull { stored -> val item = resolve(stored.id)?.takeIf { it.pinnable } ?: return@mapNotNull null - DockPinnedItem(item, stored.side, stored.width) + DockPinnedItem(item, stored.side, stored.width, stored.group) } if (root == null && floating.isEmpty() && pinned.isEmpty()) return false applyLayout(root, floating, layout.focused) applyPinned( items = pinned, - expanded = layout.pinned.filter { it.expanded }.associate { it.side to it.id }, + expanded = layout.pinned.filter { it.expanded }.associate { DockAnchor(it.side, it.group) to it.id }, visible = layout.stripesVisible, + bottomHeight = layout.bottomHeight, + bottomFraction = layout.bottomFraction, + sideSplit = layout.sideSplit, ) return true } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt index 2a4db9977..9b2e4e84d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt @@ -5,6 +5,8 @@ import ru.hollowhorizon.hollowengine.client.editor.GizmoEditMode import ru.hollowhorizon.hollowengine.client.editor.TransformGizmoEditor import ru.hollowhorizon.hollowengine.client.ui.docking.DockLang import ru.hollowhorizon.hollowengine.client.ui.docking.DockingState +import ru.hollowhorizon.hollowengine.client.ui.docking.isOnScreen +import ru.hollowhorizon.hollowengine.client.ui.docking.toggleOnScreen import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownSlider @@ -19,12 +21,15 @@ import ru.hollowhorizon.hollowengine.common.utils.openUrl private const val ReloadIcon = "hollowengine:textures/gui/icons/reload.svg" private const val ReformatIcon = "hollowengine:textures/gui/icons/code_editor.svg" private const val SaveIcon = "hollowengine:textures/gui/icons/save.svg" +private const val SaveAllIcon = "hollowengine:textures/gui/icons/save_all.svg" +private const val TelegramIcon = "hollowengine:textures/gui/icons/telegram.svg" +private const val DiscordIcon = "hollowengine:textures/gui/icons/discord.svg" +private const val ModFolderIcon = "hollowengine:textures/gui/icons/actions/reveal.svg" private const val DocsIcon = "hollowengine:textures/gui/icons/docs.svg" private const val GitHubIcon = "hollowengine:textures/gui/icons/github.svg" -private const val LinkIcon = "hollowengine:textures/gui/icons/link.svg" private const val KeyboardIcon = "hollowengine:textures/gui/icons/keyboard.svg" private const val ImportIcon = "hollowengine:textures/gui/icons/load.svg" -private const val ExportIcon = "hollowengine:textures/gui/icons/file_zip.svg" +private const val ExportIcon = "hollowengine:textures/gui/icons/export.svg" internal const val MenuLang = "hollowengine.gui.ide.menu" private const val DocsUrl = "https://0mods.team/docs/hollowengine" @@ -49,7 +54,7 @@ internal fun hollowIdeFileMenuItems( dock.updateItem(file.dockItem()) } }, - UiDropdownItem("$MenuLang.save_all".lang, SaveIcon, enabled = model.files.values.any { it.dirty }) { + UiDropdownItem("$MenuLang.save_all".lang, SaveAllIcon, enabled = model.files.values.any { it.dirty }) { model.saveAll() model.files.values.forEach { dock.updateItem(it.dockItem()) } }, @@ -78,7 +83,7 @@ internal fun hollowIdeFileMenuItems( UiDropdownItem("hollowengine.gui.ide.file.reload_server_resources".lang, ReloadIcon) { ReloadServerResourcesPacket().send() }.takeIf { operator }, - UiDropdownItem("hollowengine.gui.ide.file.open_mod_folder".lang, LogoIcon) { + UiDropdownItem("hollowengine.gui.ide.file.open_mod_folder".lang, ModFolderIcon) { DesktopUtil.openInExplorer(DirectoryManager.HOLLOW_ENGINE.toFile()) }, ) @@ -180,7 +185,7 @@ internal fun hollowIdeHelpMenuItems(onShowShortcuts: () -> Unit): List() var selectedTreePath by mutableStateOf("") private set + + /** A path the tree should scroll to, set by [revealPath] and dropped once the tree has done it. */ + var treeReveal by mutableStateOf(null) + private var revealRevision = 0 private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val pasteMutex = Mutex() private val pendingSaves = mutableMapOf() @@ -36,7 +41,7 @@ internal class HollowIdeModel( fun visibleTreeItems(filter: String, rootLabel: String): List> { return tree.visible(filter).map { node -> UiTreeItem( - id = node.path.ifEmpty { "root" }.replace('/', '-'), + id = treeItemId(node.path), label = if (node.path.isEmpty()) rootLabel else node.name, depth = node.depth, payload = node, @@ -78,8 +83,11 @@ internal class HollowIdeModel( fun revealPath(path: String) { tree.expandTo(path) selectPath(path) + treeReveal = UiTreeReveal(treeItemId(path), ++revealRevision) } + private fun treeItemId(path: String): String = path.ifEmpty { "root" }.replace('/', '-') + fun open(node: HollowIdeFileNode, additive: Boolean = false): HollowIdeOpenResult { select(node, additive) if (node.isDirectory) { 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 44bed6556..ff4651830 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 @@ -10,15 +10,15 @@ import org.lwjgl.opengl.GL30 import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.docking.* import ru.hollowhorizon.hollowengine.client.ui.ide.asset.* +import ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph.ShaderGraphEditor import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeImageEditor import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeSoundsEditor import ru.hollowhorizon.hollowengine.client.ui.ide.files.animator.HollowIdeAnimatorEditor import ru.hollowhorizon.hollowengine.client.ui.ide.files.rig.RigEditorPanel +import ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx.VfxEditorPanel import ru.hollowhorizon.hollowengine.client.ui.ide.panels.* import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSessions -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.CutsceneTimelineDock -import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorLang -import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorPanel +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineDock import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorSelection import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.ui.render.MinecraftUiRenderer @@ -62,7 +62,8 @@ internal const val AssetManagerId = "ide-asset-manager" internal const val ProjectFilterInputId = "ide-project-filter" internal const val ConsoleId = "ide-console" -internal const val CutsceneTimelineId = "ide-cutscene-timeline" +internal const val TimelineId = "ide-cutscene-timeline" +internal const val SceneId = "ide-scene" internal const val InspectorId = "ide-inspector" internal const val GameViewportId = "ide-game-viewport" internal const val GameViewportNodeId = "game-viewport" @@ -97,6 +98,8 @@ object HollowIdeOverlay { soundsEditor = { file -> HollowIdeSoundsEditor(file) }, animatorEditor = { file -> HollowIdeAnimatorEditor(file) }, rigEditor = { file -> RigEditorPanel(file) }, + vfxEditor = { file -> VfxEditorPanel(file) }, + shaderGraphEditor = { file -> ShaderGraphEditor(file) }, textEditor = { file -> FileEditor(file) }, ) registerAssetFileTypes( @@ -138,8 +141,8 @@ object HollowIdeOverlay { ) private val packaging = HollowIdeProjectPackaging(model) { statusText = it } private val console = HollowIdeConsole() - private val diagnosticsPanels = mutableStateMapOf() - private val diagnosticsPanelHeights = mutableStateMapOf() + + private var activeEditorPath by mutableStateOf(null) private var editorAnalysisRevision by mutableStateOf(0) private val editorSessions = mutableMapOf() private val editorStates = mutableMapOf() @@ -551,8 +554,8 @@ object HollowIdeOverlay { val viewport = dock.newStack(listOf(HollowIdeToolWindows.GameViewport.dockItem())) ?: return val console = dock.newStack(listOf(HollowIdeToolWindows.Console.dockItem())) ?: return val inspector = dock.newStack(listOf(HollowIdeToolWindows.Inspector.dockItem())) ?: return - val centre = dock.newSplit(DockOrientation.VERTICAL, viewport, console, fraction = 0.62f) - val withInspector = dock.newSplit(DockOrientation.HORIZONTAL, centre, inspector, fraction = 0.78f) + val center = dock.newSplit(DockOrientation.VERTICAL, viewport, console, fraction = 0.62f) + val withInspector = dock.newSplit(DockOrientation.HORIZONTAL, center, inspector, fraction = 0.78f) dock.applyLayout( root = dock.newSplit(DockOrientation.HORIZONTAL, project, withInspector, fraction = 0.2f), focused = ProjectTreeId, @@ -589,8 +592,7 @@ object HollowIdeOverlay { dock.close(id) editorSessions.remove(path)?.close() editorStates.remove(path) - diagnosticsPanels.remove(id) - diagnosticsPanelHeights.remove(id) + if (activeEditorPath == path) activeEditorPath = null findStates.remove(path) if (colorPicker?.path == path) colorPicker = null if (fileContextMenu?.path == path) fileContextMenu = null @@ -684,6 +686,7 @@ object HollowIdeOverlay { @Composable private fun Content() { RememberLayout() + TrackActiveEditor() Box( id = "ide-root", modifier = Modifier.style("hollowengine:ui/styles/widgets.hss").style("hollowengine:ui/styles/ide.hss") @@ -723,13 +726,11 @@ object HollowIdeOverlay { id = "ide-dock", modifier = Modifier.size(100.percent, 0.px).grow(1f), tabBarActions = { item -> - when (item.id) { - ProjectTreeId -> HollowIdeProjectActions(packaging) - ConsoleId -> HollowIdeConsoleToolbar(console) - } + if (item.id == ProjectTreeId) HollowIdeProjectActions(packaging) }, content = { item -> DockContent(item) }, ) + StatusBar() } HollowIdeFileContextMenu( menu = fileContextMenu, @@ -760,6 +761,69 @@ object HollowIdeOverlay { } } + @Composable + private fun TrackActiveEditor() { + val focused = focusedFile()?.takeIf { it.textOrNull != null }?.path + LaunchedEffect(focused) { + if (focused != null) activeEditorPath = focused + } + } + + private fun activeEditorFile(): HollowIdeOpenFile? { + val file = activeEditorPath?.let { model.files[it] } ?: return null + return file.takeIf { it.textOrNull != null && dock.contains(it.id) } + } + + /** Brings up the Problems page on [file], wherever the bottom tool window is parked. */ + private fun showProblems(file: HollowIdeOpenFile) { + activeEditorPath = file.path + console.tab = ConsoleTab.PROBLEMS + dock.openToolWindow(HollowIdeToolWindows.Console, model) + } + + private fun currentProblems(): HollowIdeProblems { + val file = activeEditorFile() + val diagnostics = file?.let { editorSessions[it.path]?.diagnostics(it.text) }.orEmpty() + return HollowIdeProblems(file, diagnostics) { diagnostic -> + if (file != null) { + openFileDockItem(file) + focusEditorAt(file, diagnostic.start) + } + } + } + + private val statusNavigation = HollowIdeStatusNavigation( + revealInProject = { path -> + if (path.isNotEmpty()) model.revealPath(path) + dock.openToolWindow(HollowIdeToolWindows.Project, model) + }, + showWindow = { window -> dock.openToolWindow(window, model) }, + showInspectedSource = { + val window = if (IdeScenes.current != null) HollowIdeToolWindows.Scene else HollowIdeToolWindows.Inspector + dock.openToolWindow(window, model) + }, + ) + + @Composable + private fun StatusBar() { + val navigation = statusNavigation + val project = HollowIdeCrumb(packaging.properties.displayName, ProjectIcon) { navigation.revealInProject("") } + val focused = dock.focusedItemId + val selection = InspectorSelection.current + val treePath = model.selectedTreePath + val file = focusedFile() ?: activeEditorFile() + val status = when { + focused == ProjectTreeId && treePath.isNotEmpty() -> + HollowIdeStatus(pathCrumbs(project, treePath, null, navigation.revealInProject)) + + focused == InspectorId && selection != null -> inspectorStatus(project, selection, navigation) + file != null -> fileStatus(project, file, editorStates[file.path], navigation) + selection != null -> inspectorStatus(project, selection, navigation) + else -> HollowIdeStatus(listOf(project)) + } + HollowIdeStatusBar(status, statusText) + } + @Composable private fun GearButton() { var popup by remember { mutableStateOf(false) } @@ -838,7 +902,6 @@ object HollowIdeOverlay { HollowIdeGizmoSwitcher() } Box(modifier = Modifier.size(0.px, 100.percent).grow(1f)) - Text(statusText, tags = listOf("ide-status")) ToolbarIconButton( id = "ide-toolbar-search", icon = SearchIcon, @@ -918,16 +981,12 @@ object HollowIdeOverlay { onFocusFilter = ::requestSurfaceFocus, ) - ConsoleId -> HollowIdeConsolePanel(console) - CutsceneTimelineId -> CutsceneTimelineDock( - session = CutsceneEditorSessions.default, - keyboardActive = dock.focusedItemId == CutsceneTimelineId, - ) + ConsoleId -> HollowIdeConsolePanel(console, currentProblems()) + TimelineId -> TimelineDock(keyboardActive = dock.focusedItemId == TimelineId) - InspectorId -> InspectorPanel( - target = InspectorSelection.current, - empty = InspectorLang.nothingSelected, - ) + SceneId -> SceneDock() + + InspectorId -> IdeInspectorDock() GameViewportId -> GameViewportDock( active = isGameViewportActive, @@ -964,6 +1023,8 @@ object HollowIdeOverlay { onToggle = project::toggle, onSelect = project::select, fillRowWidth = false, + reveal = model.treeReveal, + onRevealed = { model.treeReveal = null }, filterState = projectFilter, filterPlaceholder = "hollowengine.message.filter".lang, onFilterOpened = ::requestSurfaceFocus, @@ -1033,9 +1094,6 @@ object HollowIdeOverlay { val editorState = editorState(file) val analysisRevision = editorAnalysisRevision.toLong() + editorSession.revision val diagnostics = editorSession.diagnostics(file.text) - LaunchedEffect(file.id, diagnostics.isEmpty()) { - if (diagnostics.isEmpty()) diagnosticsPanels.remove(file.id) - } val inlayHints = editorSession.inlayHints(file.text) val fontSize = HollowIdeFontSize.size val editorId = "editor-${file.id}" @@ -1098,18 +1156,7 @@ object HollowIdeOverlay { HollowIdeFontSize.zoom(event.rawScrollY) event.consume() }) - HollowIdeDiagnosticsBadge(file.id, diagnostics) { id -> - diagnosticsPanels[id] = diagnosticsPanels[id] != true - } - } - if (diagnostics.isNotEmpty() && diagnosticsPanels[file.id] == true) { - val height = diagnosticsPanelHeights[file.id] ?: DefaultDiagnosticsPanelHeight - HollowIdeDiagnosticsPanel(file.id, diagnostics, height) { id, requestedHeight -> - diagnosticsPanelHeights[id] = requestedHeight.coerceIn( - MinDiagnosticsPanelHeight, - MaxDiagnosticsPanelHeight, - ) - } + HollowIdeDiagnosticsBadge(file.id, diagnostics) { showProblems(file) } } } } @@ -1118,7 +1165,6 @@ object HollowIdeOverlay { private fun EmptyEditor() { Column(tags = listOf("ide-empty-editor")) { Text(EditorLang.EMPTY.lang, tags = listOf("ide-empty-title")) - Text(statusText, tags = listOf("ide-status")) } } @@ -1230,12 +1276,11 @@ object HollowIdeOverlay { } private fun handleStripeShortcut(key: Int, modifiers: Int): Boolean { - if (modifiers and (GLFW.GLFW_MOD_CONTROL or GLFW.GLFW_MOD_SHIFT or GLFW.GLFW_MOD_SUPER) != 0) return false + if (modifiers and GLFW.GLFW_MOD_ALT == 0) return false + if (modifiers and (GLFW.GLFW_MOD_CONTROL or GLFW.GLFW_MOD_SUPER) != 0) return false val index = key - GLFW.GLFW_KEY_1 if (index < 0 || index >= StripeShortcutCount) return false - val alt = modifiers and GLFW.GLFW_MOD_ALT != 0 - if (!alt && surface.runtime.isAnyFocused) return false - val side = if (alt) DockSide.RIGHT else DockSide.LEFT + val side = if (modifiers and GLFW.GLFW_MOD_SHIFT != 0) DockSide.RIGHT else DockSide.LEFT val pinned = dock.pinnedOn(side).getOrNull(index) ?: return false dock.togglePinned(pinned.item.id) return true @@ -1649,7 +1694,3 @@ private data class EditorColorPicker( val x: Float, val y: Float, ) - -private const val DefaultDiagnosticsPanelHeight = 160f -private const val MinDiagnosticsPanelHeight = 90f -private const val MaxDiagnosticsPanelHeight = 360f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProblemsPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProblemsPanel.kt new file mode 100644 index 000000000..5233dda41 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProblemsPanel.kt @@ -0,0 +1,181 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import net.minecraft.client.Minecraft +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnosticSeverity +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import ru.hollowhorizon.hollowengine.client.utils.IconHelper +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.generated.Assets + +/** + * What the Problems page reports on: the file edited last, its diagnostics, and where a click on + * one of them leads. + */ +internal class HollowIdeProblems( + val file: HollowIdeOpenFile?, + val diagnostics: List, + val onOpen: (UiTextDiagnostic) -> Unit, +) { + val errors: Int = diagnostics.count { it.severity == UiTextDiagnosticSeverity.ERROR } + val warnings: Int = diagnostics.count { it.severity == UiTextDiagnosticSeverity.WARNING } + + /** Colors a count by the worst thing in it. */ + val severityTag: String + get() = when { + errors > 0 -> "has-errors" + warnings > 0 -> "has-warnings" + else -> "has-infos" + } +} + +/** The editor's corner summary; a click brings up the Problems page. */ +@Composable +internal fun HollowIdeDiagnosticsBadge( + fileId: String, + diagnostics: List, + onOpen: () -> Unit, +) { + if (diagnostics.isEmpty()) return + val errors = diagnostics.count { it.severity == UiTextDiagnosticSeverity.ERROR } + val warnings = diagnostics.count { it.severity == UiTextDiagnosticSeverity.WARNING } + Row( + id = "diagnostics-badge-$fileId", + tags = listOf("ide-diagnostics-badge", if (errors > 0) "has-errors" else "has-warnings"), + modifier = Modifier.align(UiAlign.END, UiAlign.START) + .input(clickable = true, hoverable = true) + .cursor(UiCursorShape.HAND) + .tooltipOnHover(ProblemsLang.SHOW.lang) + .onClick { event -> + onOpen() + event.consume() + } + ) { + SeverityCounts(errors, warnings) + } +} + +@Composable +internal fun HollowIdeProblemsList(problems: HollowIdeProblems) { + val file = problems.file + if (file == null) { + ProblemsEmpty(ProblemsLang.NO_FILE.lang) + return + } + val diagnostics = problems.diagnostics + Row(tags = listOf("problems-file")) { + Image(IconHelper.forPath(file.path, false).toString(), tags = listOf("problems-file-icon")) + Text(file.title, tags = listOf("problems-file-name")) + SeverityCounts(problems.errors, problems.warnings) + Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) + if (diagnostics.isNotEmpty()) { + CopyButton("problems-copy-all", ProblemsLang.COPY_ALL.lang) { + diagnostics.joinToString("\n", transform = UiTextDiagnostic::clipboardLine) + } + } + } + if (diagnostics.isEmpty()) { + ProblemsEmpty(ProblemsLang.NONE.lang) + return + } + Column( + tags = listOf("problems-list"), + modifier = Modifier.size(100.percent, 0.px).grow(1f).scrollable(), + ) { + diagnostics.forEachIndexed { index, diagnostic -> + key(index) { ProblemRow(index, diagnostic, problems.onOpen) } + } + } +} + +@Composable +private fun ProblemRow(index: Int, diagnostic: UiTextDiagnostic, onOpen: (UiTextDiagnostic) -> Unit) { + Row( + id = "problem-$index", + tags = listOf("problem-row", diagnostic.severity.name.lowercase()), + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .onClick { event -> + onOpen(diagnostic) + event.consume() + }, + ) { + Image(diagnostic.severity.icon, tags = listOf("problem-icon")) + Text( + diagnostic.message.lineSequence().first(), + tags = listOf("problem-message"), + modifier = Modifier.textWrap(false).textOverflow(UiTextOverflow.DOTS), + ) + Text(diagnostic.locationLabel(), tags = listOf("problem-location")) + CopyButton("problem-copy-$index", ProblemsLang.COPY.lang) { diagnostic.clipboardLine() } + } +} + +@Composable +private fun SeverityCounts(errors: Int, warnings: Int) { + if (errors > 0) { + Image(UiTextDiagnosticSeverity.ERROR.icon, tags = listOf("problem-count-icon")) + Text(errors.toString(), tags = listOf("problem-count", "has-errors")) + } + if (warnings > 0) { + Image(UiTextDiagnosticSeverity.WARNING.icon, tags = listOf("problem-count-icon")) + Text(warnings.toString(), tags = listOf("problem-count", "has-warnings")) + } +} + +@Composable +private fun ProblemsEmpty(message: String) { + Box(tags = listOf("problems-empty"), modifier = Modifier.size(100.percent, 0.px).grow(1f)) { + Text(message) + } +} + +@Composable +private fun CopyButton(id: String, tooltip: String, text: () -> String) { + Box( + id = id, + mode = UiBoxMode.STACK, + tags = listOf("problem-copy"), + modifier = Modifier.input(clickable = true, hoverable = true) + .cursor(UiCursorShape.HAND) + .onClick { event -> + Minecraft.getInstance().keyboardHandler.clipboard = text() + event.consume() + } + .tooltipOnHover(tooltip), + ) { + Image( + Assets.Hollowengine.Textures.Gui.Icons.COPY.toString(), + tags = listOf("problem-copy-icon"), + modifier = Modifier.align(UiAlign.CENTER, UiAlign.CENTER), + ) + } +} + +private val UiTextDiagnosticSeverity.icon: String + get() = when (this) { + UiTextDiagnosticSeverity.ERROR -> Assets.Hollowengine.Textures.Gui.Icons.ERROR.toString() + UiTextDiagnosticSeverity.WARNING -> Assets.Hollowengine.Textures.Gui.Icons.WARN.toString() + UiTextDiagnosticSeverity.INFO -> InfoIcon + } + +private fun UiTextDiagnostic.locationLabel(): String { + return if (line > 0 && column > 0) "$line:$column" else "-" +} + +private fun UiTextDiagnostic.clipboardLine(): String = "${locationLabel()} ${severity.name}: $message" + +private const val InfoIcon = "hollowengine:textures/gui/icons/info.svg" + +internal object ProblemsLang { + private const val ROOT = "hollowengine.gui.ide.problems." + + const val SHOW = ROOT + "show" + const val NO_FILE = ROOT + "no_file" + const val NONE = ROOT + "none" + const val COPY = ROOT + "copy" + const val COPY_ALL = ROOT + "copy_all" +} 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 0bbfd4156..3e9ed2e32 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 @@ -9,16 +9,7 @@ import androidx.compose.runtime.setValue import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect -import ru.hollowhorizon.hollowengine.client.utils.IconHelper.Icons -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.COPY -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.CREATE_FILE -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.CREATE_FOLDER -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.CUT -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.FILE_SOUND -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.FOLDER -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.PASTE -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.REMOVE -import ru.hollowhorizon.hollowengine.generated.Assets.Hollowengine.Textures.Gui.Icons.RENAME +import ru.hollowhorizon.hollowengine.client.utils.IconHelper @Composable internal fun HollowIdeProjectContextMenu( @@ -46,26 +37,26 @@ internal fun HollowIdeProjectContextMenu( tags = listOf("dropdown-popup", "project-context-menu"), onDismiss = onDismiss, ) { - ProjectMenuItem("New File", "Alt+Insert", CREATE_FILE.toString(), closeScripts) { onCreateFile(menu.path) } - ProjectMenuItem("New Folder", "Alt+Shift+Insert", CREATE_FOLDER.toString(), closeScripts) { onCreateFolder(menu.path) } + 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) } if (menu.canCreateScripts) { ProjectMenuItem( label = "New Script", shortcut = "›", - icon = Icons.FILE_KTS.toString(), + icon = IconHelper.forFile("script.kts").toString(), onEnter = { scriptsOpen = true }, onPlaced = { scriptsAnchor = it }, ) { scriptsOpen = !scriptsOpen } } if (menu.canCreateSoundEvents) { - ProjectMenuItem("New Sound Events", "", FILE_SOUND.toString(), closeScripts) { onCreateSoundEvents(menu.path) } + ProjectMenuItem("New Sound Events", "", IconHelper.forFile("sounds.ogg").toString(), closeScripts) { onCreateSoundEvents(menu.path) } } - ProjectMenuItem("Rename", "F2", RENAME.toString(), closeScripts) { onRename(menu.path) } - ProjectMenuItem("Copy", "Ctrl+C", COPY.toString(), closeScripts) { onCopy(menu.path) } - ProjectMenuItem("Cut", "Ctrl+X", CUT.toString(), closeScripts) { onCut(menu.path) } - ProjectMenuItem("Paste", "Ctrl+V", PASTE.toString(), closeScripts) { onPaste(menu.path) } - ProjectMenuItem("Show in Explorer", "", FOLDER.toString(), closeScripts) { onShowInExplorer(menu.path) } - ProjectMenuItem("Delete", "Del", REMOVE.toString(), closeScripts) { onDelete(menu.path) } + ProjectMenuItem("Rename", "F2", ProjectMenuIcons.RENAME, closeScripts) { onRename(menu.path) } + ProjectMenuItem("Copy", "Ctrl+C", ProjectMenuIcons.COPY, closeScripts) { onCopy(menu.path) } + ProjectMenuItem("Cut", "Ctrl+X", ProjectMenuIcons.CUT, closeScripts) { onCut(menu.path) } + ProjectMenuItem("Paste", "Ctrl+V", ProjectMenuIcons.PASTE, closeScripts) { onPaste(menu.path) } + ProjectMenuItem("Show in Explorer", "", ProjectMenuIcons.REVEAL, closeScripts) { onShowInExplorer(menu.path) } + ProjectMenuItem("Delete", "Del", ProjectMenuIcons.DELETE, closeScripts) { onDelete(menu.path) } } val anchor = scriptsAnchor @@ -202,3 +193,17 @@ internal fun HollowIdeFileOperationResult.statusText(): String { HollowIdeFileOperationResult.NotFound -> "hollowengine.gui.ide.file.not_found".lang } } + +/** The icons of the project menu, drawn in the family of the rest of the IDE. */ +private object ProjectMenuIcons { + private const val ROOT = "hollowengine:textures/gui/icons/actions/" + + const val NEW_FILE = ROOT + "new_file.svg" + const val NEW_FOLDER = ROOT + "new_folder.svg" + const val RENAME = ROOT + "rename.svg" + const val COPY = ROOT + "copy.svg" + const val CUT = ROOT + "cut.svg" + const val PASTE = ROOT + "paste.svg" + const val REVEAL = ROOT + "reveal.svg" + const val DELETE = ROOT + "delete.svg" +} 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 31564b998..f425f4c30 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 @@ -43,7 +43,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.source.DEFAULT_SANDBOX_NAM private const val LANG = "hollowengine.gui.ide.project" private const val SettingsIcon = "hollowengine:textures/gui/icons/options.svg" private const val ImportIcon = "hollowengine:textures/gui/icons/load.svg" -private const val ExportIcon = "hollowengine:textures/gui/icons/file_zip.svg" +private const val ExportIcon = "hollowengine:textures/gui/icons/export.svg" /** What can be done with the project as a whole, at the right end of the project tree's tab bar. */ @Composable 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 34b368bcc..348ec0864 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 @@ -1,6 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide -import ru.hollowhorizon.hollowengine.client.utils.IconHelper.Icons +import ru.hollowhorizon.hollowengine.client.utils.IconHelper import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry @@ -15,19 +15,21 @@ internal enum class ScriptTemplate( val extension: String, private val defaultName: String, private val resource: String, - val icon: String, ) { - Plain("Script", ".kts", "script", "plain.kts", Icons.FILE_KTS.toString()), - ServerReload("Server Reload Script", ".reload.kts", "server", "server.reload.kts", Icons.FILE_KTS.toString()), - ClientReload("Client Reload Script", ".reload.kts", "client", "client.reload.kts", Icons.FILE_KTS.toString()), - Startup("Startup Script", ".startup.kts", "ruby", "startup.kts", Icons.FILE_KTS.toString()), - Node("Node Script", ".node.kts", "node", "node.kts", Icons.FILE_KTS.toString()), - Ui("UI Script", ".ui.kts", "menu", "ui.kts", Icons.FILE_KTS.toString()), - Mixin("Mixin Script", ".mixin.kts", "mixins", "mixin.kts", Icons.FILE_KTS.toString()), - Dialogue("Dialogue", ".story", "dialogue", "dialogue.story", Icons.DIALOGUE.toString()); + 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"); val suggestedFileName: String get() = defaultName + extension + /** The icon the project tree gives files of this kind. */ + val icon: String get() = IconHelper.forFile(suggestedFileName).toString() + /** [input] as typed in the name dialog, completed with this template's extension when it lacks it. */ fun fileName(input: String): String { val name = input.trim() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeShortcutsDialog.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeShortcutsDialog.kt index b65ff5b63..66af66674 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeShortcutsDialog.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeShortcutsDialog.kt @@ -30,8 +30,8 @@ private val ShortcutGroups = listOf( "Ctrl+N" to "$LANG.search", "Ctrl+W" to "$LANG.close_tab", "Ctrl+S" to "$LANG.save", - "1 - 9" to "$LANG.stripe_left", - "Alt+1 - 9" to "$LANG.stripe_right", + "Alt+1 - 9" to "$LANG.stripe_left", + "Alt+Shift+1 - 9" to "$LANG.stripe_right", "F3+T" to "$LANG.reload_client", ), ), diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeStatusBar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeStatusBar.kt new file mode 100644 index 000000000..bbd87a483 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeStatusBar.kt @@ -0,0 +1,148 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.HollowEngineBuild +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget +import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow +import ru.hollowhorizon.hollowengine.client.ui.widgets.TextFieldState +import ru.hollowhorizon.hollowengine.client.utils.IconHelper +import ru.hollowhorizon.hollowengine.client.utils.lang + +/** One step of the status bar's path; [onClick] shows it where it lives, a panel or the project tree. */ +internal class HollowIdeCrumb(val label: String, val icon: String? = null, val onClick: (() -> Unit)? = null) + +/** Where the status bar's crumbs lead. */ +internal class HollowIdeStatusNavigation( + /** Selects [path] in the project tree, opening its folders and the tree itself; "" is the root. */ + val revealInProject: (path: String) -> Unit, + val showWindow: (HollowIdeToolWindow) -> Unit, + /** Where the thing the inspector shows came from, if anything is open to show it. */ + val showInspectedSource: () -> Unit, +) + +/** Where the user is, as the status bar tells it: a path on the left, facts about it on the right. */ +internal class HollowIdeStatus( + val crumbs: List, + val details: List = emptyList(), +) + +@Composable +internal fun HollowIdeStatusBar(status: HollowIdeStatus, message: String) { + Row(id = "ide-status-bar", modifier = Modifier.size(100.percent, StatusBarHeight.px)) { + Row(tags = listOf("ide-breadcrumbs"), modifier = Modifier.size(0.px, 100.percent).grow(1f).clip()) { + status.crumbs.forEachIndexed { index, crumb -> + if (index > 0) Box(tags = listOf("ide-breadcrumb-separator")) + Crumb(index, crumb, last = index == status.crumbs.lastIndex) + } + } + if (message.isNotEmpty()) { + Text( + message, + tags = listOf("ide-status-message"), + modifier = Modifier.textWrap(false).textOverflow(UiTextOverflow.DOTS), + ) + } + status.details.forEach { detail -> Text(detail, tags = listOf("ide-status-detail")) } + Text("HollowEngine ${HollowEngineBuild.VERSION}", tags = listOf("ide-status-detail", "version")) + } +} + +@Composable +private fun Crumb(index: Int, crumb: HollowIdeCrumb, last: Boolean) { + val onClick = crumb.onClick + Row( + id = "ide-breadcrumb-$index", + tags = listOfNotNull("ide-breadcrumb", "last".takeIf { last }, "link".takeIf { onClick != null }), + modifier = if (onClick == null) Modifier else Modifier.input(hoverable = true, clickable = true) + .cursor(UiCursorShape.HAND) + .onClick { event -> + onClick() + event.consume() + }, + ) { + crumb.icon?.let { icon -> Image(icon, tags = listOf("ide-breadcrumb-icon")) } + Text(crumb.label, tags = listOf("ide-breadcrumb-label"), modifier = Modifier.textWrap(false)) + } +} + +/** A file open in an editor: its place in the project, then the caret. */ +internal fun fileStatus( + project: HollowIdeCrumb, + file: HollowIdeOpenFile, + editor: TextFieldState?, + navigation: HollowIdeStatusNavigation, +): HollowIdeStatus { + val icon = IconHelper.forPath(file.path, false).toString() + val crumbs = if (file.virtual) { + val resources = HollowIdeCrumb(StatusLang.RESOURCES.lang, AssetManagerIcon) { + navigation.showWindow(HollowIdeToolWindows.AssetManager) + } + pathCrumbs(resources, file.path.removePrefix(ResourcePrefix), icon, reveal = null) + } else { + pathCrumbs(project, file.path, icon, navigation.revealInProject) + } + val details = buildList { + if (editor != null && file.textOrNull != null) add(caretLabel(editor.text, editor.caret)) + if (file.readOnly) add(StatusLang.READ_ONLY.lang) + } + return HollowIdeStatus(crumbs, details) +} + +/** Whatever the inspector is showing. */ +internal fun inspectorStatus( + project: HollowIdeCrumb, + target: InspectorTarget, + navigation: HollowIdeStatusNavigation, +): HollowIdeStatus { + val crumbs = listOfNotNull( + project, + HollowIdeCrumb(HollowIdeToolWindows.Inspector.title, OptionsIcon) { + navigation.showWindow(HollowIdeToolWindows.Inspector) + }, + HollowIdeCrumb(target.title, target.icon, navigation.showInspectedSource), + target.subtitle?.let { HollowIdeCrumb(it, onClick = navigation.showInspectedSource) }, + ) + return HollowIdeStatus(crumbs) +} + +/** + * [root], then each segment of [path]; only the last one, the target itself, gets [icon]. With + * [reveal], a click on a segment shows the folder or file it names. + */ +internal fun pathCrumbs( + root: HollowIdeCrumb, + path: String, + icon: String?, + reveal: ((String) -> Unit)?, +): List { + val segments = path.split('/').filter { it.isNotEmpty() } + return listOf(root) + segments.mapIndexed { index, segment -> + val prefix = segments.subList(0, index + 1).joinToString("/") + HollowIdeCrumb(segment, icon.takeIf { index == segments.lastIndex }, reveal?.let { { it(prefix) } }) + } +} + +/** The caret as a one-based line and column. */ +internal fun caretLabel(text: String, caret: Int): String { + val at = caret.coerceIn(0, text.length) + var line = 1 + var lineStart = 0 + for (index in 0 until at) { + if (text[index] != '\n') continue + line++ + lineStart = index + 1 + } + return StatusLang.CARET.lang(line, at - lineStart + 1) +} + +private const val StatusBarHeight = 20f +private const val ResourcePrefix = "resource://" + +internal object StatusLang { + private const val ROOT = "hollowengine.gui.ide.status." + + const val RESOURCES = ROOT + "resources" + const val READ_ONLY = ROOT + "read_only" + const val CARET = ROOT + "caret" +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt index 8e5e385af..717205ea7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeToolWindows.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide import ru.hollowhorizon.hollowengine.client.ui.docking.DockItem import ru.hollowhorizon.hollowengine.client.ui.docking.DockPlacement -import ru.hollowhorizon.hollowengine.client.ui.docking.DockSingleTabPresentation import ru.hollowhorizon.hollowengine.client.ui.docking.DockTarget import ru.hollowhorizon.hollowengine.client.ui.docking.DockingState import ru.hollowhorizon.hollowengine.client.ui.ide.asset.AssetManagerLang @@ -28,7 +27,7 @@ internal class HollowIdeToolWindow( val minWidth: Float = 48f, val minHeight: Float = 32f, val closable: Boolean = true, - val singleTabPresentation: DockSingleTabPresentation = DockSingleTabPresentation.BAR, + val titleInToolbar: Boolean = false, ) { val title: String get() = titleKey.lang @@ -39,8 +38,8 @@ internal class HollowIdeToolWindow( closable = closable, minWidth = minWidth, minHeight = minHeight, - singleTabPresentation = singleTabPresentation, pinnable = true, + titleInToolbar = titleInToolbar, ) } @@ -69,15 +68,26 @@ internal object HollowIdeToolWindows { anchors = listOf(ToolWindowAnchor.EDITORS, ToolWindowAnchor.PROJECT), minWidth = 180f, minHeight = 90f, + titleInToolbar = true, ) - val CutsceneTimeline = HollowIdeToolWindow( - id = CutsceneTimelineId, - titleKey = "hollowengine.gui.ide.windows.cutscene_timeline", + val Timeline = HollowIdeToolWindow( + id = TimelineId, + titleKey = "hollowengine.gui.ide.windows.timeline", icon = CutsceneIcon, placement = DockPlacement.BOTTOM, anchors = listOf(ToolWindowAnchor.EDITORS, ToolWindowAnchor.PROJECT), minWidth = 260f, minHeight = 130f, + titleInToolbar = true, + ) + val Scene = HollowIdeToolWindow( + id = SceneId, + titleKey = "hollowengine.gui.ide.windows.scene", + icon = "hollowengine:textures/gui/icons/layers.svg", + placement = DockPlacement.LEFT, + anchors = listOf(ToolWindowAnchor.PROJECT, ToolWindowAnchor.EDITORS), + minWidth = 160f, + minHeight = 120f, ) val Inspector = HollowIdeToolWindow( id = InspectorId, @@ -96,7 +106,6 @@ internal object HollowIdeToolWindows { anchors = listOf(ToolWindowAnchor.TIMELINE, ToolWindowAnchor.EDITORS, ToolWindowAnchor.PROJECT), minWidth = 160f, minHeight = 90f, - singleTabPresentation = DockSingleTabPresentation.OVERLAY, ) val UiProfiler = HollowIdeToolWindow( id = UiProfilerId, @@ -109,14 +118,14 @@ internal object HollowIdeToolWindows { ) val menu: List = listOf( - Project, Inspector, AssetManager, Console, + Project, Scene, Inspector, AssetManager, Console, null, - CutsceneTimeline, GameViewport, + Timeline, GameViewport, ) /** Every window the editor knows how to open, for turning a remembered id back into one. */ val all: List = listOf( - Project, AssetManager, Console, CutsceneTimeline, Inspector, GameViewport, UiProfiler, + Project, Scene, AssetManager, Console, Timeline, Inspector, GameViewport, UiProfiler, ) fun byId(id: String): HollowIdeToolWindow? = all.firstOrNull { it.id == id } @@ -132,7 +141,7 @@ internal fun DockingState.openToolWindow(window: HollowIdeToolWindow, model: Hol when (anchor) { ToolWindowAnchor.EDITORS -> model.files.values.firstOrNull { contains(it.id) }?.id ToolWindowAnchor.PROJECT -> ProjectTreeId.takeIf(::contains) - ToolWindowAnchor.TIMELINE -> CutsceneTimelineId.takeIf(::contains) + ToolWindowAnchor.TIMELINE -> TimelineId.takeIf(::contains) } } open(window.dockItem(), DockTarget(anchor, window.placement)) 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 new file mode 100644 index 000000000..8190a5ca8 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeInspectorHost.kt @@ -0,0 +1,42 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.client.ui.inspector.AssetPickerDialog +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorAssetRequest +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorHost +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorLang +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorPanel +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorSelection +import ru.hollowhorizon.hollowengine.client.ui.inspector.LocalInspectorHost + +/** + * 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. + */ +internal object IdeInspectorHost : InspectorHost { + var pending by mutableStateOf(null) + + override val canPickAssets: Boolean get() = true + + override fun pickAsset(request: InspectorAssetRequest) { + pending = request + } +} + +/** The inspector window: whatever was published last, with the IDE as its host. */ +@Composable +internal fun IdeInspectorDock() { + CompositionLocalProvider(LocalInspectorHost provides IdeInspectorHost) { + InspectorPanel( + target = InspectorSelection.current, + 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/IdeSceneHost.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeSceneHost.kt new file mode 100644 index 000000000..54119c390 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/IdeSceneHost.kt @@ -0,0 +1,167 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.* +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.ui.* +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.ui.widgets.UiTreeItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTreeView +import ru.hollowhorizon.hollowengine.client.utils.lang + +/** + * A hierarchy, that scene window shows. + */ +class SceneTarget( + val id: String, + val items: List>, + /** `null` when the selection is dropped. */ + val onSelect: (String?) -> Unit, + val onToggle: (String) -> Unit, + /** A line under the tree, for a hierarchy that has nothing in it yet. */ + val hint: String? = null, + /** What a right click offers on the item with this id, or on the empty space under the rows (null). */ + val menu: ((String?) -> List)? = null, + /** Keys pressed while the tree has focus, after the window's own; true when one was handled. */ + val onKey: ((UiKeyInput) -> Boolean)? = null, + /** Puts the dragged item under the one it was dropped on; null when the hierarchy cannot be rearranged. */ + val onMove: ((dragged: String, target: String) -> Boolean)? = null, + val canMove: (dragged: String, target: String) -> Boolean = { _, _ -> true }, +) + +/** + * Whose hierarchy the scene window shows. Last publisher wins, like the inspector. + */ +object IdeScenes { + var current: SceneTarget? by mutableStateOf(null) + private set + + private var owner: String? = null + + fun publish(source: String, target: SceneTarget?) { + if (target == null) { + release(source) + return + } + owner = source + current = target + } + + fun release(source: String) { + if (owner != source) return + owner = null + current = null + } +} + +/** + * Keeps [target] in the scene window for as long as this editor is composed. + * + * Unlike the inspector, the tree itself changes as the file is edited, so [key] normally carries the + * revision of whatever is being shown. + */ +@Composable +fun PublishScene(source: String, key: Any?, target: () -> SceneTarget?) { + LaunchedEffect(source, key) { IdeScenes.publish(source, target()) } + DisposableEffect(source) { onDispose { IdeScenes.release(source) } } +} + +/** An item of a scene tree being dragged: which hierarchy it came from, so another cannot take it. */ +private data class SceneDrag(val scene: String, val item: String) + +/** + * The scene window: the hierarchy of whatever is open, looked at and worked with the way the project + * tree is. A right click opens the menu of the item under the pointer, rows drag onto each other, and + * the selection answers the usual keys. + */ +@Composable +internal fun SceneDock() { + val target = IdeScenes.current + var menu by remember { mutableStateOf(null) } + + Column(tags = listOf("ide-panel", "scene-panel"), modifier = Modifier.size(100.percent, 100.percent)) { + if (target == null) { + SceneEmptyState("hollowengine.gui.ide.windows.scene_empty".lang, addHint = false) + return@Column + } + + val openMenu = { item: String?, event: UiEvent -> + val items = target.menu?.invoke(item).orEmpty() + menu = if (items.isEmpty()) null else SceneMenu(event.x, event.y, items) + } + val move = target.onMove + val empty = target.items.isEmpty() + + Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 0.px).grow(1f)) { + UiTreeView( + items = target.items, + onToggle = { item -> target.onToggle(item.id) }, + onSelect = { item, event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + if (!item.selected) target.onSelect(item.id) + openMenu(item.id, event) + return@UiTreeView + } + menu = null + target.onSelect(item.id.takeUnless { item.selected && event.isCtrlDown() }) + }, + onBackgroundClick = { + menu = null + target.onSelect(null) + }, + onBackgroundContextMenu = { event -> openMenu(null, event) }, + fillRowWidth = true, + dragItem = if (move == null) null else { item -> + UiDragItem(payload = SceneDrag(target.id, item.id), icon = item.icon, label = item.label) + }, + onDrop = if (move == null) null else { item, dragged -> + val drag = dragged.payload as? SceneDrag + drag != null && move(drag.item, item.id) + }, + canDrop = { item, dragged -> + val drag = dragged.payload as? SceneDrag + drag != null && drag.scene == target.id && drag.item != item.id && target.canMove(drag.item, item.id) + }, + modifier = Modifier.size(100.percent, 100.percent).onKeyInput { input -> + if (input.key == GLFW.GLFW_KEY_ESCAPE && target.items.any { it.selected }) { + target.onSelect(null) + input.consume() + return@onKeyInput + } + if (target.onKey?.invoke(input) == true) input.consume() + }, + ) + if (empty) SceneEmptyState(target.hint.orEmpty(), addHint = target.menu != null) + } + + if (!empty) target.hint?.let { Text(it, tags = listOf("scene-hint")) } + + menu?.let { open -> + ContextMenu( + id = "scene-context-menu", + anchorBounds = UiRect(open.x, open.y, 0f, 0f), + alignment = UiPopupAlignment.Cursor, + items = open.items, + onExpandedChange = { if (!it) menu = null }, + ) + } + } +} + +private class SceneMenu(val x: Float, val y: Float, val items: List) + +/** What the scene window says when there is nothing in it, framed like the inspector says it. */ +@Composable +private fun SceneEmptyState(message: String, addHint: Boolean) { + Column(tags = listOf("scene-empty"), modifier = Modifier.inputTransparent()) { + Column(tags = listOf("scene-empty-state")) { + Image(SceneEmptyIcon, tags = listOf("scene-empty-icon")) + if (message.isNotEmpty()) Text(message, tags = listOf("scene-empty-text")) + if (addHint) Text("hollowengine.gui.ide.windows.scene_add_hint".lang, tags = listOf("scene-empty-hint")) + } + } +} + +private const val SceneEmptyIcon = "hollowengine:textures/gui/icons/layers.svg" 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 706e59603..850e17349 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 @@ -29,6 +29,12 @@ class HollowIdeAnimatorDocument(bytes: ByteArray) : HollowIdeFileDocument { var revision by mutableStateOf(0) private set + /** What the editor keeps while the file stays open, such as where the view was, across tab switches. */ + private var editorState: Any? = null + + @Suppress("UNCHECKED_CAST") + fun editorState(create: () -> T): T = (editorState as? T) ?: create().also { editorState = it } + fun edit(change: (Animator) -> Animator) { val next = change(animator) if (next == animator) return 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 new file mode 100644 index 000000000..1eb63a991 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeShaderGraphDocument.kt @@ -0,0 +1,138 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraph +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphFormat +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderNodeLibrary +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeFileDocument + +/** + * An open `.material` file: a shader graph. An empty file reads as the default surface graph, so a file made from + * the project tree opens as something that already draws. + */ +class HollowIdeShaderGraphDocument(bytes: ByteArray) : HollowIdeFileDocument { + var graph by mutableStateOf(ShaderGraph()) + private set + + /** What went wrong reading the file, or null when it read fine. */ + var error by mutableStateOf(null) + private set + + private var original: String = "" + + override val readOnly: Boolean get() = error != null + + var isModified by mutableStateOf(false) + private set + + var revision by mutableStateOf(0) + private set + + private val undoStack = ArrayDeque() + private val redoStack = ArrayDeque() + + /** The graph as it was when a gesture started; the whole gesture becomes one step back. */ + private var gestureStart: ShaderGraph? = null + + var canUndo by mutableStateOf(false) + private set + var canRedo by mutableStateOf(false) + private set + + /** 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 } + + fun edit(change: (ShaderGraph) -> ShaderGraph) { + if (readOnly) return + val next = change(graph) + if (next == graph) return + if (gestureStart == null) remember(graph) + apply(next) + } + + /** Starts a gesture, such as dragging a node, that should go back in one step. */ + fun beginGesture() { + if (gestureStart == null) gestureStart = graph + } + + fun endGesture() { + val start = gestureStart ?: return + gestureStart = null + if (start != graph) remember(start) + } + + fun undo(): Boolean { + val previous = undoStack.removeLastOrNull() ?: return false + redoStack.addLast(graph) + apply(previous) + return true + } + + fun redo(): Boolean { + val next = redoStack.removeLastOrNull() ?: return false + undoStack.addLast(graph) + apply(next) + return true + } + + private fun remember(state: ShaderGraph) { + undoStack.addLast(state) + while (undoStack.size > HISTORY_LIMIT) undoStack.removeFirst() + redoStack.clear() + refreshHistoryState() + } + + private fun apply(next: ShaderGraph) { + graph = next + isModified = true + revision++ + refreshHistoryState() + } + + private fun refreshHistoryState() { + canUndo = undoStack.isNotEmpty() + canRedo = redoStack.isNotEmpty() + } + + override fun encode(): ByteArray = if (readOnly) original.toByteArray() else ShaderGraphFormat.write(graph).toByteArray() + + override fun reload(bytes: ByteArray) { + if (!readOnly && bytes.toString(Charsets.UTF_8) == ShaderGraphFormat.write(graph)) return + undoStack.clear() + redoStack.clear() + refreshHistoryState() + load(bytes) + isModified = false + revision++ + } + + override fun markSaved() { + isModified = false + } + + private fun load(bytes: ByteArray) { + original = bytes.toString(Charsets.UTF_8) + try { + graph = if (original.isBlank()) ShaderNodeLibrary.defaultSurface() else ShaderGraphFormat.read(original) + error = null + } catch (e: Exception) { + graph = ShaderGraph() + error = e.message ?: e::class.simpleName + HollowEngine.LOGGER.warn("Could not read shader graph: {}", error) + } + } + + private companion object { + const val HISTORY_LIMIT = 100 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeVfxDocument.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeVfxDocument.kt new file mode 100644 index 000000000..515b93d10 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/HollowIdeVfxDocument.kt @@ -0,0 +1,153 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeFileDocument +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import ru.hollowhorizon.hollowengine.common.vfx.VfxFormat + +/** + * An open `.vfx` file: one [VfxEffect] the editor rewrites whole. + */ +class HollowIdeVfxDocument(bytes: ByteArray) : HollowIdeFileDocument { + var effect by mutableStateOf(VfxEffect.EMPTY) + private set + + /** What went wrong reading the file, or null when it read fine. */ + var error by mutableStateOf(null) + private set + + private var original: String = "" + + override val readOnly: Boolean get() = error != null + + var isModified by mutableStateOf(false) + private set + + var revision by mutableStateOf(0) + private set + + private var editorState: Any? = null + + init { + load(bytes) + } + + @Suppress("UNCHECKED_CAST") + fun editorState(create: () -> T): T = (editorState as? T) ?: create().also { editorState = it } + + /** Effects to go back to, newest last; the timeline part is not in them, it has its own history. */ + private val undoStack = ArrayDeque() + private val redoStack = ArrayDeque() + + /** The effect as it was when a gesture started; the whole gesture becomes one step back. */ + private var gestureStart: VfxEffect? = null + private var lastMergeKey: String? = null + private var lastEditNanos = 0L + + var canUndo by mutableStateOf(false) + private set + var canRedo by mutableStateOf(false) + private set + + /** + * Replaces the effect with what [change] makes of it. + */ + fun edit(mergeKey: String? = null, history: Boolean = true, change: (VfxEffect) -> VfxEffect) { + if (readOnly) return + + val next = change(effect) + if (next == effect) return + + if (history && gestureStart == null) { + val now = System.nanoTime() + val merges = mergeKey != null && mergeKey == lastMergeKey && now - lastEditNanos < MERGE_WINDOW_NANOS + if (!merges) remember(effect) + lastMergeKey = mergeKey + lastEditNanos = now + } + apply(next) + } + + /** Starts a gesture, such as dragging a handle, that should go back in one step. */ + fun beginGesture() { + if (gestureStart == null) gestureStart = effect + } + + fun endGesture() { + val start = gestureStart ?: return + gestureStart = null + lastMergeKey = null + if (start != effect) remember(start) + } + + fun undo(): Boolean { + val previous = undoStack.removeLastOrNull() ?: return false + redoStack.addLast(effect) + lastMergeKey = null + apply(previous.copy(timeline = effect.timeline)) + return true + } + + fun redo(): Boolean { + val next = redoStack.removeLastOrNull() ?: return false + undoStack.addLast(effect) + lastMergeKey = null + apply(next.copy(timeline = effect.timeline)) + return true + } + + private fun remember(state: VfxEffect) { + undoStack.addLast(state) + while (undoStack.size > HISTORY_LIMIT) undoStack.removeFirst() + redoStack.clear() + refreshHistoryState() + } + + private fun apply(next: VfxEffect) { + effect = next + isModified = true + revision++ + refreshHistoryState() + } + + private fun refreshHistoryState() { + canUndo = undoStack.isNotEmpty() + canRedo = redoStack.isNotEmpty() + } + + override fun encode(): ByteArray = if (readOnly) original.toByteArray() else VfxFormat.write(effect).toByteArray() + + override fun reload(bytes: ByteArray) { + if (!readOnly && bytes.toString(Charsets.UTF_8) == VfxFormat.write(effect)) return + undoStack.clear() + redoStack.clear() + refreshHistoryState() + load(bytes) + isModified = false + revision++ + } + + override fun markSaved() { + isModified = false + } + + private fun load(bytes: ByteArray) { + original = bytes.toString(Charsets.UTF_8) + try { + effect = VfxFormat.read(original) + error = null + } catch (e: Exception) { + effect = VfxEffect.EMPTY + error = e.message ?: e::class.simpleName + HollowEngine.LOGGER.warn("Could not read effect: {}", error) + } + } + + private companion object { + const val HISTORY_LIMIT = 100 + const val MERGE_WINDOW_NANOS = 700_000_000L + } +} 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 ebc0ca201..8a49627c6 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 @@ -2,19 +2,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.animator import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.utils.lang -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.UiTextDiagnostic -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnosticSeverity -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextHighlight -import ru.hollowhorizon.hollowengine.client.ui.widgets.withColor -import ru.hollowhorizon.hollowengine.common.utils.expressions.Diagnostics -import ru.hollowhorizon.hollowengine.common.utils.expressions.Lexer -import ru.hollowhorizon.hollowengine.common.utils.expressions.Severity -import ru.hollowhorizon.hollowengine.common.utils.expressions.TokenType -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion -import ru.hollowhorizon.hollowengine.client.models.internal.animator.AnimationDeclarations +import ru.hollowhorizon.hollowengine.client.ui.widgets.ExpressionEditing import ru.hollowhorizon.hollowengine.client.models.internal.animator.AnimationExpressionLanguage import ru.hollowhorizon.hollowengine.common.models.AnimatorLayerType import ru.hollowhorizon.hollowengine.common.models.AnimatorLayerTypes @@ -60,13 +48,6 @@ object AnimatorColors { val EdgeHover = UiColor(0.82f, 0.86f, 0.94f) val Accent = UiColor(0.38f, 0.60f, 0.92f) val Danger = UiColor(0.85f, 0.34f, 0.34f) - - val TokenNumber = UiColor(0.72f, 0.62f, 0.92f) - val TokenString = UiColor(0.62f, 0.82f, 0.55f) - val TokenKeyword = UiColor(0.85f, 0.55f, 0.42f) - val TokenKnown = UiColor(0.42f, 0.72f, 0.92f) - val TokenUnknown = UiColor(0.85f, 0.45f, 0.45f) - val TokenOperator = UiColor(0.62f, 0.66f, 0.72f) } internal fun animatorText(name: String): String = "hollowengine.gui.animator_editor.$name".lang @@ -86,71 +67,8 @@ private fun titleOf(titleKey: String, id: String): String { return if (translated == titleKey) id.substringAfterLast('/') else translated } -val AnimationExpressionCompletions: UiCompletionContributor = UiCompletionContributor { context -> - val before = context.text.take(context.caret.coerceIn(0, context.text.length)) - val member = MemberAccess.find(before) - val prefix = member?.groupValues?.get(2) ?: before.takeLastWhile { it.isLetterOrDigit() || it == '_' } - val names = member?.let { membersOf(it.groupValues[1]) } ?: expressionNames() - - names - .filter { prefix.isEmpty() || it.startsWith(prefix, ignoreCase = true) } - .map { UiTextCompletion(label = it) } -} - -private val MemberAccess = Regex("""([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*([A-Za-z0-9_]*)$""") - -private fun membersOf(root: String): List { - val type = AnimationDeclarations.root(root)?.type - ?: AnimationDeclarations.receivers.firstOrNull { it.name == root }?.type - ?: return emptyList() - - return (type.members.allFields.map { it.name } + type.members.allMethods.map { it.name }) - .distinct() - .sorted() -} - -val AnimationExpressionHighlighter = UiSyntaxHighlighter { text -> - if (text.isBlank()) return@UiSyntaxHighlighter emptyList() - - Lexer(text, Diagnostics()).tokenize().mapNotNull { token -> - val color = when (token.type) { - TokenType.NUMBER -> AnimatorColors.TokenNumber - TokenType.STRING -> AnimatorColors.TokenString - TokenType.BOOLEAN -> AnimatorColors.TokenKeyword - TokenType.IDENTIFIER -> AnimatorColors.TokenKnown - TokenType.EOF -> return@mapNotNull null - else -> AnimatorColors.TokenOperator - } - UiTextHighlight(token.span.start, token.span.end, UiInlineStyle().withColor(color)) - } -} - -fun animationExpressionDiagnostics(source: String): List { - if (source.isBlank()) return emptyList() - - return runCatching { AnimationExpressionLanguage.bake(source).diagnostics } - .getOrDefault(emptyList()) - .map { diagnostic -> - UiTextDiagnostic( - start = diagnostic.span.start.coerceIn(0, source.length), - end = diagnostic.span.end.coerceIn(0, source.length), - message = diagnostic.message, - severity = when (diagnostic.severity) { - Severity.ERROR -> UiTextDiagnosticSeverity.ERROR - Severity.WARNING -> UiTextDiagnosticSeverity.WARNING - }, - ) - } -} - -private fun expressionNames(): List { - val declarations = AnimationDeclarations - val roots = declarations.roots.keys - val members = declarations.receivers.flatMap { receiver -> - receiver.type.members.allFields.map { it.name } + receiver.type.members.allMethods.map { it.name } - } - return (roots + members).distinct().sorted() -} +/** Highlighting, completion and diagnostics for the expressions of the animator dialect. */ +val AnimationExpressionEditing = ExpressionEditing(AnimationExpressionLanguage) internal fun UiColor.mixedWith(other: UiColor, amount: Float = 0.22f): UiColor = interpolate(other.copy(alpha = alpha), amount) 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 1daa27493..7134be107 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 @@ -1,35 +1,65 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.animator -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import org.lwjgl.glfw.GLFW -import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.Image +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Row +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.UiAlign +import ru.hollowhorizon.hollowengine.client.ui.UiLength +import ru.hollowhorizon.hollowengine.client.ui.alignItems +import ru.hollowhorizon.hollowengine.client.ui.gap +import ru.hollowhorizon.hollowengine.client.ui.graph.ARROW_SIZE +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphCanvas +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphCurves +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphEdge +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphLinkPreview +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphMiniMapItem +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphNode +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphRect +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphViewState +import ru.hollowhorizon.hollowengine.client.ui.grow import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeAnimatorDocument +import ru.hollowhorizon.hollowengine.client.ui.input import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect -import ru.hollowhorizon.hollowengine.client.ui.shape.GenericShape -import ru.hollowhorizon.hollowengine.client.ui.style.UiGradientStop -import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint -import ru.hollowhorizon.hollowengine.client.ui.style.UiShadow -import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow +import ru.hollowhorizon.hollowengine.client.ui.onClick +import ru.hollowhorizon.hollowengine.client.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.position +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.size 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 ru.hollowhorizon.hollowengine.common.models.* -import kotlin.math.* +import ru.hollowhorizon.hollowengine.common.models.ANY_STATE +import ru.hollowhorizon.hollowengine.common.models.AnimationControllerLayerSpec +import ru.hollowhorizon.hollowengine.common.models.AnimationControllerStateSpec +import ru.hollowhorizon.hollowengine.common.models.AnimationControllerTransitionSpec +import ru.hollowhorizon.hollowengine.common.models.AnimationPlayMode +import ru.hollowhorizon.hollowengine.common.models.Animator +import ru.hollowhorizon.hollowengine.common.models.AnimatorStateTypes +import ru.hollowhorizon.hollowengine.common.models.ClipStateSpec +import ru.hollowhorizon.hollowengine.common.models.GraphPoint +import ru.hollowhorizon.hollowengine.common.models.nodeAt +import ru.hollowhorizon.hollowengine.common.models.nodeLayout +import ru.hollowhorizon.hollowengine.common.models.withAnyStateAt +import ru.hollowhorizon.hollowengine.common.models.withEntryState +import ru.hollowhorizon.hollowengine.common.models.withNodeAt +import ru.hollowhorizon.hollowengine.common.models.withState +import ru.hollowhorizon.hollowengine.common.models.withStateAsAnyState +import ru.hollowhorizon.hollowengine.common.models.withTransition +import ru.hollowhorizon.hollowengine.common.models.withoutAnyState +import ru.hollowhorizon.hollowengine.common.models.withoutState +import ru.hollowhorizon.hollowengine.common.models.withoutTransitionAt internal const val AnimatorCanvasId = "animator-canvas" private const val NodeHeight = 46f private const val NodeMinWidth = 96f private const val NodeMaxWidth = 260f -private const val GridStep = 32f -private const val ArrowSize = 9f -private const val CurveBow = 26f -private const val ParallelOffset = 9f -private const val EdgeSamples = 32 -private const val EdgeHitRadius = 6f -private const val MiniMapWidth = 120f -private const val MiniMapHeight = 78f -private const val MinZoom = 0.45f -private const val MaxZoom = 2.0f +private const val ParallelOffset = 14f private const val PlayIcon = "hollowengine:textures/gui/icons/play.svg" private const val StateIcon = "hollowengine:textures/gui/icons/state.svg" private const val AnyStateIcon = "hollowengine:textures/gui/icons/any_state.svg" @@ -37,526 +67,195 @@ private const val ResetIcon = "hollowengine:textures/gui/icons/reload.svg" internal const val MaximizeIcon = "hollowengine:textures/gui/icons/maximize.svg" internal const val MinimizeIcon = "hollowengine:textures/gui/icons/minimize.svg" -/** - * Pan and zoom of one graph. - */ -class AnimatorCanvasState { - var panX by mutableStateOf(60f) - var panY by mutableStateOf(60f) - var zoom by mutableStateOf(1f) - private set - - private var targetZoom = 1f - private var velocity = 0f - private var anchorScreenX = 0f - private var anchorScreenY = 0f - private var anchorGraphX = 0f - private var anchorGraphY = 0f - private var lastFrame = 0L - - fun toScreenX(x: Float) = x * zoom + panX - fun toScreenY(y: Float) = y * zoom + panY - fun toGraphX(x: Float) = (x - panX) / zoom - fun toGraphY(y: Float) = (y - panY) / zoom - - fun zoomBy(step: Float, aroundX: Float, aroundY: Float) { - anchorScreenX = aroundX - anchorScreenY = aroundY - anchorGraphX = toGraphX(aroundX) - anchorGraphY = toGraphY(aroundY) - targetZoom = (targetZoom * step).coerceIn(MinZoom, MaxZoom) - } - - fun reset() { - targetZoom = 1f - velocity = 0f - panX = 60f - panY = 60f - zoom = 1f - } - - /** One step of the spring; returns whether anything is still moving. */ - fun advance(frameNanos: Long): Boolean { - val previous = lastFrame - lastFrame = frameNanos - if (previous == 0L) return false - - val delta = targetZoom - zoom - if (abs(delta) < 0.0005f && abs(velocity) < 0.0005f) { - if (zoom != targetZoom) { - zoom = targetZoom - reanchor() - } - velocity = 0f - return false - } - - val dt = ((frameNanos - previous) / 1_000_000_000f).coerceIn(0f, 0.05f) - velocity += (delta * STIFFNESS - velocity * DAMPING) * dt - zoom += velocity * dt - reanchor() - return true - } - - private fun reanchor() { - if (anchorScreenX == 0f && anchorScreenY == 0f) return - panX = anchorScreenX - anchorGraphX * zoom - panY = anchorScreenY - anchorGraphY * zoom - } - - private companion object { - const val STIFFNESS = 260f - const val DAMPING = 30f - } -} - -/** A state as it sits on screen right now. */ -private data class NodeBox(val id: String, val x: Float, val y: Float, val width: Float, val height: Float) { - val centerX get() = x + width / 2f - val centerY get() = y + height / 2f +/** 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 object Empty : CanvasMenuTarget } -/** A transition as a sampled curve, which is what gets drawn, clicked and labeled. */ -private data class EdgeCurve(val index: Int, val points: FloatArray) { - /** - * A point along the curve. - */ - fun pointAt(fraction: Float): FloatArray { - val samples = points.size / 2 - 1 - val step = (fraction.coerceIn(0f, 1f) * samples).toInt().coerceIn(0, samples) - return floatArrayOf(points[step * 2], points[step * 2 + 1]) - } - - val labelPoint: FloatArray get() = pointAt(0.42f + (index % 3) * 0.11f) +private data class CanvasMenu(val target: CanvasMenuTarget, val screenX: Float, val screenY: Float, val at: GraphPoint) - override fun equals(other: Any?) = other is EdgeCurve && index == other.index && points.contentEquals(other.points) - override fun hashCode() = 31 * index + points.contentHashCode() -} +/** A link being drawn out of a state with Ctrl held, toward the pointer. */ +private data class PendingLink(val from: String, val canvasX: Float, val canvasY: Float) /** - * State machine of controller layer, as a graph. + * The state machine of a controller layer, as a graph: states are nodes, transitions are arrows, and a + * Ctrl-drag from one state to another adds a transition. */ @Composable internal fun AnimatorGraphCanvas( document: HollowIdeAnimatorDocument, layerId: String, + controller: AnimationControllerLayerSpec, selection: AnimatorSelection, - view: AnimatorCanvasState, - chrome: CanvasChrome, + view: GraphViewState, onSelect: (AnimatorSelection) -> Unit, modifier: Modifier, ) { val animator = document.animator - val controller = animator.controller(layerId) - var canvasSize by remember { mutableStateOf(UiRect.Zero) } + var canvas by remember { mutableStateOf(UiRect.Zero) } + var menu by remember(layerId) { mutableStateOf(null) } + var link by remember(layerId) { mutableStateOf(null) } + val dragOrigin = remember(layerId) { floatArrayOf(0f, 0f) } - if (controller == null) { - Box( - mode = UiBoxMode.STACK, - modifier = modifier.background(AnimatorColors.Canvas).onPlaced { canvasSize = it }) { - Text( - animatorText("pick_controller"), - modifier = Modifier.padding(12.px).fontSize(11f).foreground(AnimatorColors.Muted), - ) - CanvasChromeButtons(chrome, null) - } - return + val positions = animator.nodeLayout(layerId) + anyStatePosition(animator, layerId, controller) + val boxes = positions.mapValues { (stateId, point) -> + GraphRect(point.x, point.y, nodeWidth(stateId, controller.state(stateId).subtitle()), NodeHeight) } - - LaunchedEffect(view) { - while (true) { - withFrameNanos(view::advance) + val onCanvas = boxes.mapValues { (_, box) -> box.toCanvas(view) } + val transitionCurves = controller.transitionCurves(onCanvas, view.zoom) + + /** Ends a link being drawn: a transition to the state it was dropped on, or nothing. */ + fun finishLink() { + val drawn = link ?: return + link = null + val target = onCanvas.entries.firstOrNull { it.value.contains(drawn.canvasX, drawn.canvasY) }?.key + if (target != null && target != drawn.from && target != ANY_STATE) { + document.edit { it.withTransition(layerId, AnimationControllerTransitionSpec(from = drawn.from, to = target)) } } } - val positions = animator.nodeLayout(layerId) + anyStatePosition(animator, layerId, controller) - var measured by remember(layerId) { mutableStateOf(emptyMap()) } - val boxes = positions.map { (stateId, point) -> - val state = controller.states.firstOrNull { it.id == stateId } - val size = measured[stateId] - NodeBox( - id = stateId, - x = view.toScreenX(point.x), - y = view.toScreenY(point.y), - width = size?.width ?: (nodeWidth(stateId, state.subtitle()) * view.zoom), - height = size?.height ?: (NodeHeight * view.zoom), - ) - } - val boxById = boxes.associateBy { it.id } - val edges = controller.edgeCurves(boxById) - - var dragging by remember(layerId) { mutableStateOf(null) } - var dragOrigin by remember(layerId) { mutableStateOf(GraphPoint()) } - var linkingFrom by remember(layerId) { mutableStateOf(null) } - var linkX by remember(layerId) { mutableStateOf(0f) } - var linkY by remember(layerId) { mutableStateOf(0f) } - var menu by remember(layerId) { mutableStateOf(null) } - var hoveredEdge by remember(layerId) { mutableStateOf(null) } - - Box( + val pending = link + GraphCanvas( id = AnimatorCanvasId, - mode = UiBoxMode.STACK, - modifier = modifier - .background(AnimatorColors.Canvas) - .clip(true) - .onPlaced { canvasSize = it } - .input(hoverable = true, clickable = true, draggable = true) - .drawBehind( - GraphKey( - view.zoom, - view.panX, - view.panY, - document.revision, - selection, - linkingFrom, - linkX, - linkY, - hoveredEdge - ) - ) { - drawGrid(view) - edges.sortedBy { selection == AnimatorSelection.Transition(layerId, it.index) }.forEach { edge -> - val color = when { - selection == AnimatorSelection.Transition(layerId, edge.index) -> AnimatorColors.EdgeSelected - edge.index == hoveredEdge -> AnimatorColors.EdgeHover - else -> AnimatorColors.Edge - } - drawCurve(edge.points, view.zoom, color) - } - linkingFrom?.let { from -> - val box = boxById[from] ?: return@let - drawLine(box.centerX, box.centerY, linkX, linkY, AnimatorColors.Accent) - } - } - .onHover { event -> - val edge = edges.nearest(event.localX, event.localY) - if (edge?.index != hoveredEdge) hoveredEdge = edge?.index - } - .onExit { hoveredEdge = null } - .focus() - .onKeyInput { input -> - when { - input.key == GLFW.GLFW_KEY_DELETE -> { - when (selection) { - is AnimatorSelection.State -> - if (selection.stateId == ANY_STATE) { - document.edit { it.withoutAnyState(layerId) } - } else { - document.edit { it.withoutState(layerId, selection.stateId) } - } - - is AnimatorSelection.Transition -> - document.edit { it.withoutTransitionAt(layerId, selection.index) } - - else -> return@onKeyInput - } - onSelect(AnimatorSelection.Layer(layerId)) - input.consume() - } - - input.key == GLFW.GLFW_KEY_ESCAPE -> { - onSelect(AnimatorSelection.None) - input.consume() - } - - input.key == GLFW.GLFW_KEY_F -> { - view.reset() - input.consume() - } - - input.key == GLFW.GLFW_KEY_E -> { - val target = selection as? AnimatorSelection.State ?: return@onKeyInput - document.edit { it.withEntryState(layerId, target.stateId) } - input.consume() - } - - input.key == GLFW.GLFW_KEY_INSERT || (input.control && input.key == GLFW.GLFW_KEY_N) -> { - val id = freeStateId(controller) - document.edit { - it.withState( - layerId, - ClipStateSpec(id = id, animation = id), - at = GraphPoint( - view.toGraphX(canvasSize.width / 2f), - view.toGraphY(canvasSize.height / 2f) - ), - ) - } - onSelect(AnimatorSelection.State(layerId, id)) - input.consume() - } - } - } - .onScroll { event -> - view.zoomBy(if (event.rawScrollY > 0f) 1.18f else 1f / 1.18f, event.localX, event.localY) - event.consume() + view = view, + modifier = modifier, + edges = transitionCurves.map { (index, curve) -> + GraphEdge(index, curve, arrow = true, selected = selection == AnimatorSelection.Transition(layerId, index)) + }, + link = pending?.let { from -> + val box = onCanvas[from.from] ?: return@let null + GraphLinkPreview(box.centerX, box.centerY, from.canvasX, from.canvasY) + }, + minimap = boxes.map { (stateId, box) -> + GraphMiniMapItem( + rect = box, + color = when { + selection == AnimatorSelection.State(layerId, stateId) -> AnimatorColors.NodeSelected + stateId == ANY_STATE -> AnimatorColors.AnyState + else -> AnimatorColors.Edge + }, + tooltip = if (stateId == ANY_STATE) animatorText("any_state_node") else stateId, + ) + }, + onBackgroundClick = { onSelect(AnimatorSelection.None) }, + onRelease = ::finishLink, + onEdgeClick = { index -> onSelect(AnimatorSelection.Transition(layerId, index as Int)) }, + onContextMenu = { pointer, edge -> + val target = (edge as? Int)?.let(CanvasMenuTarget::Transition) ?: CanvasMenuTarget.Empty + menu = CanvasMenu(target, pointer.screenX, pointer.screenY, GraphPoint(pointer.graphX, pointer.graphY)) + }, + onKey = { input -> + handleKey(document, layerId, controller, selection, onSelect, input.key, input.control) { + GraphPoint(view.toGraphX(canvas.width / 2f), view.toGraphY(canvas.height / 2f)) } - .onDrag { event -> - view.panX += event.deltaX - view.panY += event.deltaY - event.consume() + }, + overlay = { + Row(modifier = Modifier.position(6.px, 6.px).gap(4.px)) { + AnimatorIconButton(ResetIcon, animatorText("reset_view"), size = 11f) { view.reset() } } - .onClick { event -> - when { - event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT -> { - menu = CanvasMenu( - target = boxes.firstOrNull { it.contains(event.localX, event.localY) }?.id - ?: edges.nearest(event.localX, event.localY)?.let { EdgeTarget(it.index) }, - screenX = event.x, - screenY = event.y, - graphX = view.toGraphX(event.localX), - graphY = view.toGraphY(event.localY), - ) - } - - else -> { - val edge = edges.nearest(event.localX, event.localY) - onSelect( - if (edge != null) AnimatorSelection.Transition(layerId, edge.index) - else AnimatorSelection.None - ) - } - } - event.consume() - }, + }, ) { - edges.sortedBy { selection == AnimatorSelection.Transition(layerId, it.index) }.forEach { edge -> - val label = edge.labelPoint + transitionCurves.forEach { (index, 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 = controller.transitions[edge.index], - selected = selection == AnimatorSelection.Transition(layerId, edge.index), - zoom = view.zoom, - x = label[0], - y = label[1], - onSelect = { onSelect(AnimatorSelection.Transition(layerId, edge.index)) }, + transition = transition, + selected = selection == AnimatorSelection.Transition(layerId, index), + x = view.toGraphX(label[0]), + y = view.toGraphY(label[1]), + onSelect = { onSelect(AnimatorSelection.Transition(layerId, index)) }, ) } - boxes.forEach { box -> - val state = controller.states.firstOrNull { it.id == box.id } - StateNode( - box = box, - subtitle = state.subtitle(), - playMode = (state as? ClipStateSpec)?.playMode, - isEntry = controller.entryState == box.id, - selected = selection == AnimatorSelection.State(layerId, box.id), - zoom = view.zoom, - onSelect = { onSelect(AnimatorSelection.State(layerId, box.id)) }, - onPressed = { linking -> - if (linking) { - linkingFrom = box.id - linkX = box.centerX - linkY = box.centerY + boxes.forEach { (stateId, box) -> + val state = controller.state(stateId) + val anyState = stateId == ANY_STATE + GraphNode( + id = "animator-state-$stateId", + canvasId = AnimatorCanvasId, + view = view, + x = box.x, + y = box.y, + width = box.width, + height = box.height, + selected = selection == AnimatorSelection.State(layerId, stateId), + tags = listOfNotNull( + "animator-state", + "any-state".takeIf { anyState }, + "entry".takeIf { controller.entryState == stateId }, + ), + onPress = { gesture -> + onSelect(AnimatorSelection.State(layerId, stateId)) + if (gesture.control) { + link = PendingLink(stateId, onCanvas.getValue(stateId).centerX, onCanvas.getValue(stateId).centerY) } else { - dragging = box.id - dragOrigin = positions[box.id] ?: GraphPoint() + dragOrigin[0] = box.x + dragOrigin[1] = box.y } }, - onDragged = { event -> - val from = linkingFrom - if (from != null) { - val local = event.ancestorLocalPositions[AnimatorCanvasId] - if (local != null) { - linkX = local.x - linkY = local.y - } - return@StateNode - } - if (dragging != box.id) return@StateNode - document.edit { - it.withNodeAt( - layerId, - box.id, - GraphPoint( - dragOrigin.x + event.dragTotalX / view.zoom, - dragOrigin.y + event.dragTotalY / view.zoom, - ), - ) - } - }, - onReleased = { - val from = linkingFrom - if (from != null) { - val target = boxes.firstOrNull { it.contains(linkX, linkY) }?.id - if (target != null && target != from && target != ANY_STATE) { - document.edit { - it.withTransition(layerId, AnimationControllerTransitionSpec(from = from, to = target)) - } + onDrag = { gesture -> + if (link != null) { + link = link?.copy(canvasX = gesture.canvasX, canvasY = gesture.canvasY) + } else { + document.edit { + it.withNodeAt( + layerId, + stateId, + GraphPoint(dragOrigin[0] + gesture.graphDeltaX, dragOrigin[1] + gesture.graphDeltaY), + ) } } - linkingFrom = null - dragging = null }, + onRelease = ::finishLink, onContextMenu = { screenX, screenY -> - menu = CanvasMenu(box.id, screenX, screenY, 0f, 0f) + onSelect(AnimatorSelection.State(layerId, stateId)) + menu = CanvasMenu(CanvasMenuTarget.State(stateId), screenX, screenY, GraphPoint(box.x, box.y)) }, - onMeasured = { rect -> - if (measured[box.id]?.width != rect.width || measured[box.id]?.height != rect.height) { - measured = measured + (box.id to rect) - } - }, - ) - } - - Column( - modifier = Modifier.size(100.percent, 100.percent).padding(8.px).inputTransparent() - .alignItems(horizontal = UiAlign.END, vertical = UiAlign.END), - ) { - MiniMap( - positions = positions, - selected = (selection as? AnimatorSelection.State)?.stateId, - modifier = Modifier, - ) { point -> - view.panX = canvasSize.width / 2f - point.x * view.zoom - view.panY = canvasSize.height / 2f - point.y * view.zoom + ) { + StateNodeContent( + stateId = stateId, + subtitle = state.subtitle(), + playMode = (state as? ClipStateSpec)?.playMode, + isEntry = controller.entryState == stateId, + ) } } - CanvasChromeButtons(chrome, view) } - CanvasContextMenu( - menu = menu, - controller = controller, - layerId = layerId, - document = document, - view = view, - onSelect = onSelect, - onDismiss = { menu = null }, - ) + menu?.let { open -> + CanvasContextMenu(open, controller, layerId, document, view, onSelect) { menu = null } + } } +/** The name on top with the entry mark beside it, and under it what the state plays and how. */ @Composable -private fun StateNode( - box: NodeBox, - subtitle: String?, - playMode: AnimationPlayMode?, - isEntry: Boolean, - selected: Boolean, - zoom: Float, - onSelect: () -> Unit, - onPressed: (linking: Boolean) -> Unit, - onDragged: (UiEvent) -> Unit, - onReleased: () -> Unit, - onMeasured: (UiRect) -> Unit, - onContextMenu: (Float, Float) -> Unit, -) { - val anyState = box.id == ANY_STATE - val accent = when { - selected -> AnimatorColors.NodeSelected - anyState -> AnimatorColors.AnyState - isEntry -> AnimatorColors.NodeEntry - else -> AnimatorColors.Border +private fun StateNodeContent(stateId: String, subtitle: String?, playMode: AnimationPlayMode?, isEntry: Boolean) { + val anyState = stateId == ANY_STATE + Row(tags = listOf("animator-state-row"), modifier = Modifier.size(100.percent).alignItems(vertical = UiAlign.CENTER)) { + Image(if (anyState) AnyStateIcon else StateIcon, tags = listOf("animator-state-icon")) + Text( + if (anyState) animatorText("any_state_node") else stateId, + tags = listOf("graph-node-title"), + modifier = Modifier.size(0.px, UiLength.Auto).grow(1f), + ) + if (isEntry) Image(PlayIcon, tags = listOf("animator-entry-badge")) } - - Column( - tags = listOf("animator-node"), - modifier = Modifier - .position(box.x.px, box.y.px) - .size((nodeWidth(box.id, subtitle) * zoom).px) - .background( - 145f, - listOf( - UiGradientStop(0f, AnimatorColors.NodeTop.mixedWith(accent)), - UiGradientStop(1f, AnimatorColors.NodeBottom), - ), - ) - .border(if (selected) 2.px else 1.px, accent, 5f) - .shadow(UiShadow(offset = UiVec3(0f, 2f), blur = 9f, color = AnimatorColors.NodeShadow)) - .padding((5f * zoom).px) - .gap((2f * zoom).px) - .input(hoverable = true, clickable = true, draggable = true) - .onPlaced(onMeasured) - .onPress { event -> - if (event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { - onSelect() - onContextMenu(event.x, event.y) - event.consume() - return@onPress - } - if (event.button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return@onPress - onSelect() - onPressed(event.modifiers and GLFW.GLFW_MOD_CONTROL != 0) - event.consume() - } - .onDrag { event -> - onDragged(event) - event.consume() - } - .onRelease { event -> - onReleased() - event.consume() - }, - ) { - Row(modifier = Modifier.size(100.percent).gap((4f * zoom).px).alignItems(vertical = UiAlign.CENTER)) { - Image( - if (anyState) AnyStateIcon else StateIcon, - modifier = Modifier.size((9f * zoom).px, (9f * zoom).px), - ) - NodeLine( - if (anyState) "Any State" else box.id, - 10f * zoom, - if (anyState) AnimatorColors.AnyState else AnimatorColors.Text, - ) - } - if (!anyState) { - NodeLine(subtitle.orEmpty(), 8f * zoom, AnimatorColors.Muted) - Row(modifier = Modifier.size(100.percent).gap((3f * zoom).px).alignItems(vertical = UiAlign.CENTER)) { - playMode?.let { PlayModeChip(it, zoom) } - Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) - if (isEntry) EntryBadge(zoom) - } - } + if (anyState) return + Row(tags = listOf("animator-state-row"), modifier = Modifier.size(100.percent).alignItems(vertical = UiAlign.CENTER)) { + Text(subtitle.orEmpty(), tags = listOf("graph-node-subtitle"), modifier = Modifier.size(0.px, UiLength.Auto).grow(1f)) + playMode?.let { Text(it.name.lowercase(), tags = listOf("animator-play-mode")) } } } -/** The playback mode, as a chip: on its own line it reads as the tail of a wrapped animation name. */ -@Composable -private fun PlayModeChip(playMode: AnimationPlayMode, zoom: Float) { - Text( - playMode.name.lowercase(), - modifier = Modifier - .background(AnimatorColors.Chip) - .border(1.px, AnimatorColors.Border, 3f) - .borderRadius(3f) - .padding((4f * zoom).px, (1f * zoom).px) - .fontSize(7f * zoom) - .foreground(AnimatorColors.ChipText) - .textWrap(false), - ) -} - -/** Marks the state a controller starts in, in the corner where it cannot be hidden by the selection border. */ -@Composable -private fun EntryBadge(zoom: Float) { - Image( - PlayIcon, - modifier = Modifier - .size((13f * zoom).px, (13f * zoom).px) - .background(AnimatorColors.Panel) - .border(1.px, AnimatorColors.NodeEntry, 3f) - .borderRadius(3f) - .padding((2f * zoom).px), - ) -} - -@Composable -private fun NodeLine(text: String, fontSize: Float, color: UiColor) { - Text( - text, - modifier = Modifier - .size(100.percent) - .fontSize(fontSize) - .foreground(color) - .textWrap(false) - .textOverflow(UiTextOverflow.DOTS), - ) -} - @Composable private fun TransitionLabel( transition: AnimationControllerTransitionSpec, selected: Boolean, - zoom: Float, x: Float, y: Float, onSelect: () -> Unit, @@ -564,11 +263,9 @@ private fun TransitionLabel( val label = transition.condition.source.ifBlank { animatorText("always") } Text( if (label.length > 20) label.take(19) + "…" else label, - tags = listOf("animator-edge-label") + if (selected) listOf("selected") else emptyList(), + tags = listOfNotNull("graph-link-label", "selected".takeIf { selected }), modifier = Modifier - .position((x - 24f * zoom).px, (y - 7f * zoom).px) - .fontSize(8f * zoom) - .foreground(if (selected) AnimatorColors.EdgeSelected else AnimatorColors.Muted) + .position((x - 24f).px, (y - 7f).px) .input(hoverable = true, clickable = true) .onClick { event -> if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onSelect() @@ -577,357 +274,142 @@ private fun TransitionLabel( ) } -/** - * Handles the graph shows for the surrounding panels. - */ -internal data class CanvasChrome( - val layersFolded: Boolean, - val foldLayers: @Composable () -> Unit, -) +/** The keys the graph answers besides the canvas's own: delete, drop the selection, entry, a new state. */ +private fun handleKey( + document: HollowIdeAnimatorDocument, + layerId: String, + controller: AnimationControllerLayerSpec, + selection: AnimatorSelection, + onSelect: (AnimatorSelection) -> Unit, + key: Int, + control: Boolean, + middle: () -> GraphPoint, +): Boolean { + when { + key == GLFW.GLFW_KEY_DELETE -> { + when (selection) { + is AnimatorSelection.State -> document.edit { + if (selection.stateId == ANY_STATE) it.withoutAnyState(layerId) else it.withoutState(layerId, selection.stateId) + } -/** - * Handles that fold the panels, and one that puts the view back. - */ -@Composable -private fun CanvasChromeButtons(chrome: CanvasChrome, view: AnimatorCanvasState?) { - Row(modifier = Modifier.position(6.px, 6.px).gap(4.px)) { - chrome.foldLayers() - if (view != null) { - AnimatorIconButton(ResetIcon, animatorText("reset_view"), size = 11f) { view.reset() } + is AnimatorSelection.Transition -> document.edit { it.withoutTransitionAt(layerId, selection.index) } + else -> return false + } + onSelect(AnimatorSelection.Layer(layerId)) } - } -} -@Composable -private fun MiniMap( - positions: Map, - selected: String?, - modifier: Modifier, - onJumpTo: (GraphPoint) -> Unit, -) { - if (positions.isEmpty()) return + key == GLFW.GLFW_KEY_ESCAPE -> onSelect(AnimatorSelection.None) - val minX = positions.values.minOf { it.x } - val maxX = positions.values.maxOf { it.x } + NodeMinWidth - val minY = positions.values.minOf { it.y } - val maxY = positions.values.maxOf { it.y } + NodeHeight - val scale = minOf( - (MiniMapWidth - 8f) / (maxX - minX).coerceAtLeast(1f), - (MiniMapHeight - 8f) / (maxY - minY).coerceAtLeast(1f), - ) + key == GLFW.GLFW_KEY_E -> { + val target = selection as? AnimatorSelection.State ?: return false + document.edit { it.withEntryState(layerId, target.stateId) } + } - Box( - mode = UiBoxMode.STACK, - modifier = modifier - .size(MiniMapWidth.px, MiniMapHeight.px) - .background(AnimatorColors.Panel) - .border(1.px, AnimatorColors.Border, 4f) - .borderRadius(4f), - ) { - positions.forEach { (stateId, point) -> - MiniMapMark( - stateId = stateId, - selected = stateId == selected, - x = 4f + (point.x - minX) * scale, - y = 4f + (point.y - minY) * scale, - width = (NodeMinWidth * scale).coerceAtLeast(4f), - height = (NodeHeight * scale).coerceAtLeast(3f), - onClick = { onJumpTo(point) }, - ) + key == GLFW.GLFW_KEY_INSERT || control && key == GLFW.GLFW_KEY_N -> { + val id = freeStateId(controller) + document.edit { it.withState(layerId, ClipStateSpec(id = id, animation = id), at = middle()) } + onSelect(AnimatorSelection.State(layerId, id)) } + + else -> return false } + return true } -@Composable -private fun MiniMapMark( - stateId: String, - selected: Boolean, - x: Float, - y: Float, - width: Float, - height: Float, - onClick: () -> Unit, -) { - Box( - tags = listOf("animator-minimap-mark"), - modifier = Modifier - .position(x.px, y.px) - .size(width.px, height.px) - .background( - when { - selected -> AnimatorColors.NodeSelected - stateId == ANY_STATE -> AnimatorColors.AnyState - else -> AnimatorColors.Edge - } - ) - .input(hoverable = true, clickable = true) - .tooltipOnHover(if (stateId == ANY_STATE) animatorText("any_state_node") else stateId) - .onPress { event -> - if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onClick() - event.consume() - }, - ) -} - -private data class EdgeTarget(val index: Int) - -private data class CanvasMenu( - val target: Any?, - val screenX: Float, - val screenY: Float, - val graphX: Float, - val graphY: Float, -) - @Composable private fun CanvasContextMenu( - menu: CanvasMenu?, + menu: CanvasMenu, controller: AnimationControllerLayerSpec, layerId: String, document: HollowIdeAnimatorDocument, - view: AnimatorCanvasState, + view: GraphViewState, onSelect: (AnimatorSelection) -> Unit, onDismiss: () -> Unit, ) { - if (menu == null) return - ContextMenu( id = "animator-canvas-context", anchorBounds = UiRect(menu.screenX, menu.screenY, 0f, 0f), - items = buildList { - when (val target = menu.target) { - ANY_STATE -> add(UiDropdownItem(animatorText("delete_any_state")) { - document.edit { it.withoutAnyState(layerId) } - onSelect(AnimatorSelection.Layer(layerId)) - }) - - is String -> { - add(UiDropdownItem(animatorText("make_entry")) { - document.edit { it.withEntryState(layerId, target) } - }) - add(UiDropdownItem(animatorText("make_any_state")) { - document.edit { it.withStateAsAnyState(layerId, target) } - onSelect(AnimatorSelection.State(layerId, ANY_STATE)) - }) - add(UiDropdownItem(animatorText("delete_state")) { - document.edit { it.withoutState(layerId, target) } - onSelect(AnimatorSelection.Layer(layerId)) - }) - } - - is EdgeTarget -> add(UiDropdownItem(animatorText("delete_transition")) { - document.edit { it.withoutTransitionAt(layerId, target.index) } - onSelect(AnimatorSelection.Layer(layerId)) - }) - - else -> { - val kinds = AnimatorStateTypes.all.filter { it.createDefault != null } - kinds.forEach { type -> - val create = type.createDefault ?: return@forEach - val label = if (kinds.size == 1) animatorText("add_state") - else "${animatorText("add_state")}: ${type.title()}" - - add(UiDropdownItem(label) { - val id = freeStateId(controller) - document.edit { - it.withState(layerId, create(id), at = GraphPoint(menu.graphX, menu.graphY)) - } - onSelect(AnimatorSelection.State(layerId, id)) - }) - } - if (ANY_STATE !in document.animator.nodeLayout(layerId).keys && - controller.transitions.none { it.from == ANY_STATE } - ) { - add(UiDropdownItem(animatorText("add_any_state")) { - document.edit { it.withAnyStateAt(layerId, GraphPoint(menu.graphX, menu.graphY)) } - onSelect(AnimatorSelection.State(layerId, ANY_STATE)) - }) - } - add(UiDropdownItem(animatorText("reset_view")) { view.reset() }) - } - } + 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) } + onSelect(AnimatorSelection.Layer(layerId)) + }) + + CanvasMenuTarget.Empty -> addStateItems(document, layerId, controller, menu.at, onSelect) + + UiDropdownItem(animatorText("reset_view")) { view.reset() } }, onExpandedChange = { if (!it) onDismiss() }, ) } -private fun UiCanvasDrawScope.drawGrid(view: AnimatorCanvasState) { - val paint = UiPaint.Color(AnimatorColors.Grid) - val step = GridStep * view.zoom - if (step < 4f) return - - var x = view.panX.mod(step) - while (x < size.width) { - drawRect(UiRect(x, 0f, 1f, size.height), paint) - x += step - } - var y = view.panY.mod(step) - while (y < size.height) { - drawRect(UiRect(0f, y, size.width, 1f), paint) - y += step - } -} - -/** - * Draws a sampled curve, arrowhead included, with every segment clipped to the canvas. - */ -private fun UiCanvasDrawScope.drawCurve(points: FloatArray, zoom: Float, color: UiColor) { - val width = (1.5f * zoom).coerceIn(1f, 3f) - for (index in 0 until points.size / 2 - 1) { - drawLine(points[index * 2], points[index * 2 + 1], points[index * 2 + 2], points[index * 2 + 3], color, width) +/** What can be done to one state; the scene window offers the same. */ +internal fun stateMenu( + document: HollowIdeAnimatorDocument, + layerId: String, + stateId: String, + onSelect: (AnimatorSelection) -> Unit, +): List { + if (stateId == ANY_STATE) { + return listOf(UiDropdownItem(animatorText("delete_any_state")) { + document.edit { it.withoutAnyState(layerId) } + onSelect(AnimatorSelection.Layer(layerId)) + }) } - - val head = ArrowSize * 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] - drawLine(tipX, tipY, tipX - head * cos(angle - ARROW_SPREAD), tipY - head * sin(angle - ARROW_SPREAD), color, width) - drawLine(tipX, tipY, tipX - head * cos(angle + ARROW_SPREAD), tipY - head * sin(angle + ARROW_SPREAD), color, width) + return listOf( + UiDropdownItem(animatorText("make_entry")) { document.edit { it.withEntryState(layerId, stateId) } }, + UiDropdownItem(animatorText("make_any_state")) { + document.edit { it.withStateAsAnyState(layerId, stateId) } + onSelect(AnimatorSelection.State(layerId, ANY_STATE)) + }, + UiDropdownItem(animatorText("delete_state")) { + document.edit { it.withoutState(layerId, stateId) } + onSelect(AnimatorSelection.Layer(layerId)) + }, + ) } -private fun UiCanvasDrawScope.drawLine( - x1: Float, - y1: Float, - x2: Float, - y2: Float, - color: UiColor, - width: Float = 1.5f, -) { - val clipped = clipToBounds(x1, y1, x2, y2, size.width, size.height) ?: return - val shape = GenericShape { - moveTo(clipped[0], clipped[1]) - lineTo(clipped[2], clipped[3]) +/** One item per kind of state that can be made, placed at [at], and the Any State node while there is none. */ +internal fun addStateItems( + document: HollowIdeAnimatorDocument, + layerId: String, + controller: AnimationControllerLayerSpec, + at: GraphPoint, + onSelect: (AnimatorSelection) -> Unit, +): List = buildList { + val kinds = AnimatorStateTypes.all.filter { it.createDefault != null } + kinds.forEach { type -> + val create = type.createDefault ?: return@forEach + val label = if (kinds.size == 1) animatorText("add_state") else "${animatorText("add_state")}: ${type.title()}" + add(UiDropdownItem(label) { + val id = freeStateId(controller) + document.edit { it.withState(layerId, create(id), at = at) } + onSelect(AnimatorSelection.State(layerId, id)) + }) } - drawShape(shape, bounds, UiPaint.Color(color), UiDrawStyle.Stroke(width)) -} - -private fun clipToBounds( - 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 - 0f), dx to (width - x1), -dy to (y1 - 0f), 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 - } - } + if (ANY_STATE !in document.animator.nodeLayout(layerId).keys && controller.transitions.none { it.from == ANY_STATE }) { + add(UiDropdownItem(animatorText("add_any_state")) { + document.edit { it.withAnyStateAt(layerId, at) } + onSelect(AnimatorSelection.State(layerId, ANY_STATE)) + }) } - - return floatArrayOf(x1 + enter * dx, y1 + enter * dy, x1 + exit * dx, y1 + exit * dy) } -private fun AnimationControllerLayerSpec.edgeCurves(boxes: Map): List = +/** Every transition between two placed states, as a curve on the canvas, keyed by its index. */ +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 (from.id == to.id) return@mapIndexedNotNull null + if (transition.from == transition.to) return@mapIndexedNotNull null val twoWay = transitions.any { it.from == transition.to && it.to == transition.from } val side = if (twoWay && transition.from > transition.to) -1f else 1f - EdgeCurve(index, curveBetween(from, to, side, twoWay)) + val offset = if (twoWay) ParallelOffset * zoom * side else 0f + index to GraphCurves.betweenBoxes(from, to, offset, gap = ARROW_SIZE * zoom * 0.5f) } -/** - * A link the way node editors draw one: out of a side, in through a side, eased in and out. - */ -private fun curveBetween(from: NodeBox, to: NodeBox, side: Float, twoWay: Boolean): FloatArray { - val horizontal = abs(to.centerX - from.centerX) >= abs(to.centerY - from.centerY) - val offset = if (twoWay) ParallelOffset * side else 0f - - val start = from.sidePoint(towards = to, horizontal = horizontal, along = offset) - val end = to.sidePoint(towards = from, horizontal = horizontal, along = offset, gap = ArrowSize * 0.5f) - - val reach = if (horizontal) { - (abs(end[0] - start[0]) * 0.6f).coerceIn(CurveBow, 220f) - } else { - (abs(end[1] - start[1]) * 0.6f).coerceIn(CurveBow, 220f) - } - val startHandleX = start[0] + if (horizontal) reach * start[2] else 0f - val startHandleY = start[1] + if (horizontal) 0f else reach * start[3] - val endHandleX = end[0] + if (horizontal) reach * end[2] else 0f - val endHandleY = end[1] + if (horizontal) 0f else reach * end[3] - - val points = FloatArray((EdgeSamples + 1) * 2) - for (step in 0..EdgeSamples) { - val t = step / EdgeSamples.toFloat() - val inverse = 1f - t - val a = inverse * inverse * inverse - val b = 3f * inverse * inverse * t - val c = 3f * inverse * t * t - val d = t * t * t - points[step * 2] = a * start[0] + b * startHandleX + c * endHandleX + d * end[0] - points[step * 2 + 1] = a * start[1] + b * startHandleY + c * endHandleY + d * end[1] - } - return points -} - -/** - * Where a link attaches to this box, and which way it leaves. - */ -private fun NodeBox.sidePoint( - towards: NodeBox, - horizontal: Boolean, - along: Float, - gap: Float = 0f, -): FloatArray = if (horizontal) { - val right = towards.centerX >= centerX - floatArrayOf( - if (right) x + width + gap else x - gap, - (centerY + along).coerceIn(y + 4f, y + height - 4f), - if (right) 1f else -1f, - 0f, - ) -} else { - val below = towards.centerY >= centerY - floatArrayOf( - (centerX + along).coerceIn(x + 4f, x + width - 4f), - if (below) y + height + gap else y - gap, - 0f, - if (below) 1f else -1f, - ) -} - -private fun List.nearest(x: Float, y: Float): EdgeCurve? = - mapNotNull { edge -> - val distance = (0 until edge.points.size / 2 - 1).minOfOrNull { index -> - distanceToSegment( - x, y, - edge.points[index * 2], edge.points[index * 2 + 1], - edge.points[index * 2 + 2], edge.points[index * 2 + 3], - ) - } ?: Float.MAX_VALUE - if (distance <= EdgeHitRadius) edge to distance else null - }.minByOrNull { it.second }?.first - -private fun distanceToSegment(x: Float, y: Float, x1: Float, y1: Float, x2: Float, y2: Float): Float { - val dx = x2 - x1 - val dy = y2 - y1 - val lengthSquared = dx * dx + dy * dy - if (lengthSquared == 0f) return hypot(x - x1, y - y1) - val t = (((x - x1) * dx + (y - y1) * dy) / lengthSquared).coerceIn(0f, 1f) - return hypot(x - (x1 + t * dx), y - (y1 + t * dy)) -} - -private fun NodeBox.contains(x: Float, y: Float): Boolean = - x >= this.x && x <= this.x + width && y >= this.y && y <= this.y + height +private fun AnimationControllerLayerSpec.state(stateId: String): AnimationControllerStateSpec? = + states.firstOrNull { it.id == stateId } private fun AnimationControllerStateSpec?.subtitle(): String? = when (this) { null -> null @@ -956,17 +438,3 @@ internal fun freeStateId(controller: AnimationControllerLayerSpec): String { while ("state_$index" in taken) index++ return "state_$index" } - -private data class GraphKey( - val zoom: Float, - val panX: Float, - val panY: Float, - val revision: Int, - val selection: AnimatorSelection, - val linkingFrom: String?, - val linkX: Float, - val linkY: Float, - val hoveredEdge: Int?, -) - -private const val ARROW_SPREAD = 0.5f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorSceneTree.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorSceneTree.kt new file mode 100644 index 000000000..a0db22cd5 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorSceneTree.kt @@ -0,0 +1,144 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.animator + +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.ui.ide.SceneTarget +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeAnimatorDocument +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTreeItem +import ru.hollowhorizon.hollowengine.common.models.ANY_STATE +import ru.hollowhorizon.hollowengine.common.models.AnimatorLayerTypes +import ru.hollowhorizon.hollowengine.common.models.GraphPoint +import ru.hollowhorizon.hollowengine.common.models.controller +import ru.hollowhorizon.hollowengine.common.models.nodeAt +import ru.hollowhorizon.hollowengine.common.models.withLayer +import ru.hollowhorizon.hollowengine.common.models.withoutAnyState +import ru.hollowhorizon.hollowengine.common.models.withoutLayer +import ru.hollowhorizon.hollowengine.common.models.withoutState + +private const val LayerIcon = "hollowengine:textures/gui/icons/layers.svg" +private const val StateIcon = "hollowengine:textures/gui/icons/state.svg" +private const val AnyStateIcon = "hollowengine:textures/gui/icons/any_state.svg" + +/** Tree row ids: a layer, and a state under it, which is only unique together with its layer. */ +private const val LayerRow = "layer:" +private const val StateRow = "state:" + +private fun layerRow(layerId: String) = LayerRow + layerId +private fun stateRow(layerId: String, stateId: String) = "$StateRow$layerId/$stateId" + +/** The row a tree id stands for, as the selection it makes. */ +private fun selectionOf(row: String): AnimatorSelection? = when { + row.startsWith(LayerRow) -> AnimatorSelection.Layer(row.removePrefix(LayerRow)) + row.startsWith(StateRow) -> row.removePrefix(StateRow).split('/', limit = 2) + .takeIf { it.size == 2 }?.let { (layer, state) -> AnimatorSelection.State(layer, state) } + + else -> null +} + +internal fun animatorSceneTarget( + document: HollowIdeAnimatorDocument, + selection: AnimatorSelection, + expanded: Collection, + onToggleLayer: (String) -> Unit, + onSelect: (AnimatorSelection) -> Unit, +) = SceneTarget( + id = "animator-layers", + items = buildList { + document.animator.layers.forEach { layer -> + val controller = document.animator.controller(layer.id) + val states = controller?.states?.map { it.id }.orEmpty() + + listOfNotNull(ANY_STATE.takeIf { document.animator.nodeAt(layer.id, ANY_STATE) != null }) + add( + UiTreeItem( + id = layerRow(layer.id), + label = "${layer.id} · ${layer.kindName()}", + depth = 0, + payload = layer, + icon = LayerIcon, + hasChildren = states.isNotEmpty(), + expanded = layer.id in expanded, + selected = selection == AnimatorSelection.Layer(layer.id), + ) + ) + if (layer.id !in expanded) return@forEach + states.forEach { stateId -> + add( + UiTreeItem( + id = stateRow(layer.id, stateId), + label = if (stateId == ANY_STATE) animatorText("any_state_node") else stateId, + depth = 1, + payload = null, + icon = if (stateId == ANY_STATE) AnyStateIcon else StateIcon, + selected = selection == AnimatorSelection.State(layer.id, stateId), + ) + ) + } + } + }, + onSelect = { row -> onSelect(row?.let(::selectionOf) ?: AnimatorSelection.None) }, + onToggle = { row -> if (row.startsWith(LayerRow)) onToggleLayer(row.removePrefix(LayerRow)) }, + hint = animatorText("no_layers").takeIf { document.animator.layers.isEmpty() }, + menu = { row -> sceneMenu(document, row?.let(::selectionOf), onSelect) }, + onKey = { input -> handleSceneKey(document, selection, onSelect, input) }, +) + +/** What a right click offers: adding a layer anywhere, and what can be done to the row under it. */ +private fun sceneMenu( + document: HollowIdeAnimatorDocument, + target: AnimatorSelection?, + onSelect: (AnimatorSelection) -> Unit, +): List { + val addLayer = UiDropdownItem( + animatorText("add_layer"), + children = AnimatorLayerTypes.all.mapNotNull { type -> + val createDefault = type.createDefault ?: return@mapNotNull null + UiDropdownItem(type.title()) { + val id = freeLayerId(document.animator, type.id.substringAfterLast('/')) + document.edit { it.withLayer(createDefault().withCommon(id = id)) } + onSelect(AnimatorSelection.Layer(id)) + } + }, + ) + return when (target) { + is AnimatorSelection.Layer -> buildList { + add(addLayer) + document.animator.controller(target.layerId)?.let { controller -> + addAll(addStateItems(document, target.layerId, controller, GraphPoint(), onSelect)) + } + add(UiDropdownItem(animatorText("delete_layer"), separatorBefore = true) { + document.edit { it.withoutLayer(target.layerId) } + onSelect(AnimatorSelection.None) + }) + } + + is AnimatorSelection.State -> stateMenu(document, target.layerId, target.stateId, onSelect) + else -> listOf(addLayer) + } +} + +private fun handleSceneKey( + document: HollowIdeAnimatorDocument, + selection: AnimatorSelection, + onSelect: (AnimatorSelection) -> Unit, + input: UiKeyInput, +): Boolean { + if (input.key != GLFW.GLFW_KEY_DELETE) return false + when (selection) { + is AnimatorSelection.Layer -> { + document.edit { it.withoutLayer(selection.layerId) } + onSelect(AnimatorSelection.None) + } + + is AnimatorSelection.State -> { + document.edit { + if (selection.stateId == ANY_STATE) it.withoutAnyState(selection.layerId) + else it.withoutState(selection.layerId, selection.stateId) + } + onSelect(AnimatorSelection.Layer(selection.layerId)) + } + + else -> return false + } + return true +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorWidgets.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorWidgets.kt index 86d8d448d..fa0351fe1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorWidgets.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/animator/AnimatorWidgets.kt @@ -1,9 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.animator import androidx.compose.runtime.Composable -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.ui.* import ru.hollowhorizon.hollowengine.client.ui.inspector.TextRow @@ -11,62 +8,6 @@ import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover internal const val AnimatorStylesheet = "hollowengine:ui/styles/animator-editor.hss" -/** - * A number that springs towards its target instead of jumping there. - */ -internal class SpringFloat(initial: Float) { - var value by mutableStateOf(initial) - private set - - var target: Float = initial - - private var velocity = 0f - private var lastFrame = 0L - - /** Puts the value there at once, for changes the pointer is already animating by hand. */ - fun snapTo(next: Float) { - target = next - value = next - velocity = 0f - } - - fun advance(frameNanos: Long) { - val previous = lastFrame - lastFrame = frameNanos - if (previous == 0L) return - - val delta = target - value - if (kotlin.math.abs(delta) < 0.05f && kotlin.math.abs(velocity) < 0.05f) { - value = target - velocity = 0f - return - } - - val dt = ((frameNanos - previous) / 1_000_000_000f).coerceIn(0f, 0.05f) - velocity += (delta * 300f - velocity * 32f) * dt - value += velocity * dt - } -} - -@Composable -internal fun AnimatorButton( - label: String, - modifier: Modifier = Modifier, - color: UiColor = AnimatorColors.Text, - onClick: () -> Unit, -) { - Box( - mode = UiBoxMode.STACK, - tags = listOf("animator-button"), - modifier = modifier.input(hoverable = true, clickable = true).onClick { event -> - if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onClick() - event.consume() - }, - ) { - Text(label, tags = listOf("animator-button-label"), modifier = Modifier.foreground(color)) - } -} - @Composable internal fun AnimatorIconButton( icon: String, @@ -94,8 +35,8 @@ internal fun AnimatorIconButton( internal fun ExpressionField(label: String, value: String, onChange: (String) -> Unit) = TextRow( label = label, value = value, - completions = AnimationExpressionCompletions, - highlighter = AnimationExpressionHighlighter, - diagnostics = animationExpressionDiagnostics(value), + completions = AnimationExpressionEditing.completions, + highlighter = AnimationExpressionEditing.highlighter, + diagnostics = AnimationExpressionEditing.diagnostics(value), onChange = onChange, ) 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 99bd76258..0365a281d 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 @@ -1,48 +1,48 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files.animator -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay -import org.lwjgl.glfw.GLFW -import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.focusScope +import ru.hollowhorizon.hollowengine.client.ui.gap +import ru.hollowhorizon.hollowengine.client.ui.graph.GraphViewState import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOpenFile +import ru.hollowhorizon.hollowengine.client.ui.ide.PublishScene import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeAnimatorDocument import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector -import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect -import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow -import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem -import ru.hollowhorizon.hollowengine.common.models.* -import kotlin.time.Duration.Companion.milliseconds +import ru.hollowhorizon.hollowengine.client.ui.padding +import ru.hollowhorizon.hollowengine.client.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.style +import ru.hollowhorizon.hollowengine.common.models.Animator +import ru.hollowhorizon.hollowengine.common.models.controller private const val AutoSaveDelayMillis = 900L -private const val LayerListWidth = 190f - -private const val MinPanelWidth = 150f -private const val MaxPanelWidth = 420f /** * The editor for a `.animator` file. * - * The stack of layers on the left, and what the selected layer is: a graph for a controller, nothing but - * parameters for a clip. A layer with no states has no graph to show, so it does not get an empty one. + * The stack of layers lives in the scene window, with the states of each controller under it; the + * editor itself is the graph of the layer being worked on. A clip layer has no states, so it has no + * graph, and the inspector is all there is to it. */ @Composable internal fun HollowIdeAnimatorEditor(file: HollowIdeOpenFile) { val document = file.document as HollowIdeAnimatorDocument - val view = remember(document) { AnimatorCanvasState() } + 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) } - var layersFolded by remember(document) { mutableStateOf(false) } - - var layersSize by remember(document) { mutableStateOf(LayerListWidth) } - val layersWidth = remember(document) { SpringFloat(LayerListWidth) } - layersWidth.target = if (layersFolded) 0f else layersSize - - LaunchedEffect(document) { - while (true) { - withFrameNanos { frame -> layersWidth.advance(frame) } - } - } + val expanded = remember(document) { mutableStateListOf().apply { addAll(document.animator.layers.map { it.id }) } } LaunchedEffect(document.revision) { file.updateDirty(document.isModified) @@ -53,11 +53,15 @@ internal fun HollowIdeAnimatorEditor(file: HollowIdeOpenFile) { val layers = document.animator.layers val current = openLayer?.takeIf { id -> layers.any { it.id == id } } ?: layers.firstOrNull()?.id - val graphed = current?.let { document.animator.controller(it) } != null fun retarget(next: AnimatorSelection) { selection = next - (next as? AnimatorSelection.Layer)?.let { openLayer = it.layerId } + when (next) { + is AnimatorSelection.Layer -> openLayer = next.layerId + is AnimatorSelection.State -> openLayer = next.layerId + is AnimatorSelection.Transition -> openLayer = next.layerId + AnimatorSelection.None -> Unit + } } val shown = if (selection != AnimatorSelection.None) selection @@ -65,194 +69,35 @@ internal fun HollowIdeAnimatorEditor(file: HollowIdeOpenFile) { PublishInspector(source = "animator-${file.path}", key = shown) { animatorInspectorTarget(document, shown, ::retarget) } - - val foldButton: @Composable () -> Unit = { - AnimatorIconButton( - icon = if (layersFolded) MaximizeIcon else MinimizeIcon, - tooltip = if (layersFolded) animatorText("show_layers") else animatorText("hide_layers"), - size = 11f, - ) { layersFolded = !layersFolded } + PublishScene(source = "animator-${file.path}", key = SceneKey(document.revision, shown, expanded.toList())) { + animatorSceneTarget(document, shown, expanded, { layerId -> if (!expanded.remove(layerId)) expanded += layerId }, ::retarget) } - Row( - modifier = Modifier.size(100.percent, 100.percent) - .style(AnimatorStylesheet) - .background(AnimatorColors.Canvas) - .focusScope(), + val controller = current?.let { document.animator.controller(it) } + Column( + modifier = Modifier.size(100.percent, 100.percent).style(AnimatorStylesheet).focusScope(), ) { - Box(mode = UiBoxMode.STACK, modifier = Modifier.size(layersWidth.value.px, 100.percent).clip(true)) { - LayerList( - document = document, - openLayer = current, - selection = selection, - onOpen = { id -> - openLayer = id - selection = AnimatorSelection.Layer(id) - }, - modifier = Modifier.size(layersSize.px, 100.percent), - ) - } - - if (!layersFolded) { - Splitter(layersSize) { next -> - layersSize = next.coerceIn(MinPanelWidth, MaxPanelWidth) - layersWidth.snapTo(layersSize) - } - } - - if (graphed) { + if (current != null && controller != null) { AnimatorGraphCanvas( document = document, layerId = current, + controller = controller, selection = selection, view = view, - chrome = CanvasChrome(layersFolded = layersFolded, foldLayers = foldButton), onSelect = ::retarget, - modifier = Modifier.size(0.px, 100.percent).grow(1f), + modifier = Modifier.size(100.percent, 100.percent), ) } else { - Column(modifier = Modifier.size(0.px, 100.percent).grow(1f).padding(10.px).gap(6.px)) { - Row(modifier = Modifier.size(100.percent).gap(6.px).alignItems(vertical = UiAlign.CENTER)) { - foldButton() - Text( - current ?: animatorText("no_layers"), - modifier = Modifier.fontSize(11f).foreground(AnimatorColors.Text), - ) - } - Text( - animatorText("no_graph"), - modifier = Modifier.fontSize(9f).foreground(AnimatorColors.Muted), - ) + Column(tags = listOf("animator-empty"), modifier = Modifier.padding(10.px).gap(6.px)) { + Text(current ?: animatorText("no_layers"), tags = listOf("animator-empty-title")) + Text(animatorText(if (current == null) "no_layers_hint" else "no_graph"), tags = listOf("animator-empty-hint")) } } } } -/** - * Drag to give a panel more or less room. - */ -@Composable -private fun Splitter(width: Float, onResize: (Float) -> Unit) { - val start = remember { floatArrayOf(width) } - Box( - tags = listOf("animator-splitter"), - modifier = Modifier - .input(hoverable = true, draggable = true) - .onPress { start[0] = width } - .onDrag { event -> - onResize(start[0] + event.dragTotalX) - event.consume() - }, - ) -} - -@Composable -private fun LayerList( - document: HollowIdeAnimatorDocument, - openLayer: String?, - selection: AnimatorSelection, - onOpen: (String) -> Unit, - modifier: Modifier, -) { - var addButton by remember { mutableStateOf(UiRect.Zero) } - var addMenuOpen by remember { mutableStateOf(false) } - - Column( - modifier = modifier - .background(AnimatorColors.Panel) - .border(1.px, AnimatorColors.Border) - .padding(8.px) - .gap(4.px), - ) { - Text(animatorText("layers"), modifier = Modifier.fontSize(11f).foreground(AnimatorColors.Muted)) - - Column(modifier = Modifier.size(100.percent, 0.px).grow(1f).gap(3.px).scrollable(horizontal = false)) { - document.animator.layers.forEach { layer -> - LayerRow( - layer = layer, - open = layer.id == openLayer, - selected = selection == AnimatorSelection.Layer(layer.id), - onOpen = { onOpen(layer.id) }, - onDelete = { document.edit { it.withoutLayer(layer.id) } }, - ) - } - - AnimatorButton( - animatorText("add_layer"), - modifier = Modifier.size(100.percent, 24.px).onPlaced { addButton = it }, - ) { - addMenuOpen = true - } - } - } - - if (addMenuOpen) { - ContextMenu( - id = "animator-add-layer", - anchorBounds = addButton, - items = AnimatorLayerTypes.all.mapNotNull { type -> - val createDefault = type.createDefault ?: return@mapNotNull null - UiDropdownItem(type.title()) { - val id = freeLayerId(document.animator, type.id.substringAfterLast('/')) - document.edit { it.withLayer(createDefault().withCommon(id = id)) } - onOpen(id) - } - }, - onExpandedChange = { if (!it) addMenuOpen = false }, - ) - } -} - -@Composable -private fun LayerRow( - layer: AnimatorLayerSpec, - open: Boolean, - selected: Boolean, - onOpen: () -> Unit, - onDelete: () -> Unit, -) { - Row( - tags = listOf("animator-layer-row") + - (if (open) listOf("open") else emptyList()) + - (if (selected) listOf("selected") else emptyList()), - modifier = Modifier - .input(hoverable = true, clickable = true) - .onPress { event -> - if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onOpen() - event.consume() - }, - ) { - Column(modifier = Modifier.size(0.px, 100.percent).grow(1f).clip(true)) { - Text( - layer.id, - modifier = Modifier.size(100.percent).fontSize(9f).foreground(AnimatorColors.Text) - .textWrap(false).textOverflow(UiTextOverflow.DOTS), - ) - Text( - "${layer.kindName()} · ${layer.priority}", - modifier = Modifier.size(100.percent).fontSize(8f).foreground(AnimatorColors.Muted) - .textWrap(false).textOverflow(UiTextOverflow.DOTS), - ) - } - DeleteHandle(onDelete) - } -} - -@Composable -private fun DeleteHandle(onDelete: () -> Unit) { - Box( - mode = UiBoxMode.STACK, - tags = listOf("animator-delete"), - modifier = Modifier - .input(hoverable = true, clickable = true) - .onPress { event -> - if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onDelete() - event.consume() - }, - ) { - Text("×", tags = listOf("animator-delete-label")) - } -} +/** What the scene tree is built from; a new one of these rebuilds it. */ +private data class SceneKey(val revision: Int, val selection: AnimatorSelection, val expanded: List) internal fun freeLayerId(animator: Animator, prefix: String): String { val taken = animator.layers.map { it.id }.toSet() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderExpressionEditing.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderExpressionEditing.kt new file mode 100644 index 000000000..034bed200 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderExpressionEditing.kt @@ -0,0 +1,74 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderExpression +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderExpressionError +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderInput +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderTokenKind +import ru.hollowhorizon.hollowengine.client.ui.widgets.* + +/** + * What a field needs to edit the text of an expression node: the token colors every expression field + * of the editor uses, the functions and names it can complete, and what is wrong with the text. + */ +internal object ShaderExpressionEditing { + val highlighter = UiSyntaxHighlighter { text -> + ShaderExpression.parse(text).tokens.mapNotNull { token -> + val color = when (token.kind) { + ShaderTokenKind.NUMBER -> ExpressionTokenColors.Number + ShaderTokenKind.VARIABLE -> ExpressionTokenColors.Name + ShaderTokenKind.CONSTANT -> ExpressionTokenColors.Number + ShaderTokenKind.BOOLEAN -> ExpressionTokenColors.Keyword + ShaderTokenKind.FUNCTION -> ExpressionTokenColors.Keyword + ShaderTokenKind.MEMBER -> ExpressionTokenColors.String + ShaderTokenKind.OPERATOR -> ExpressionTokenColors.Operator + ShaderTokenKind.END -> return@mapNotNull null + } + UiTextHighlight(token.start, token.end, UiInlineStyle().withColor(color)) + } + } + + val completions = UiCompletionContributor { context -> + val before = context.text.take(context.caret.coerceIn(0, context.text.length)) + val prefix = before.takeLastWhile { it.isLetterOrDigit() || it == '_' } + if (before.dropLast(prefix.length).endsWith('.')) { + return@UiCompletionContributor Swizzles.filter { it.startsWith(prefix) } + .map { UiTextCompletion(label = it) } + } + val names = ShaderExpression.parse(context.text).variables.filter { it != prefix } + val candidates = ShaderExpression.Functions.map { (name, parameters) -> + UiTextCompletion(label = name, insertText = "$name()", detail = parameters, caretOffset = name.length + 1) + } + (ShaderExpression.Constants.keys + ShaderExpression.Booleans).map { + UiTextCompletion( + label = it, + tail = "const" + ) + } + ShaderInput.entries.mapNotNull { input -> + input.expressionName?.let { + UiTextCompletion( + label = it, + tail = input.type.glsl + ) + } + } + names.map { UiTextCompletion(label = it, tail = "pin") } + candidates.distinctBy { it.label }.filter { prefix.isEmpty() || it.label.startsWith(prefix, ignoreCase = true) } + } + + fun diagnostics(text: String): List { + val error = ShaderExpression.parse(text).error ?: return emptyList() + if (text.isEmpty()) return emptyList() + val start = error.start.coerceIn(0, text.length - 1) + val end = error.end.coerceIn(start + 1, text.length) + return listOf(UiTextDiagnostic(start, end, describe(error))) + } + + fun describe(error: ShaderExpressionError): String = + expressionProblemText(error.problem.name.lowercase(), error.detail) + + private val Swizzles = listOf("x", "y", "z", "w", "xy", "xyz", "rgb", "rgba", "a") +} + +/** What a problem of an expression says, in words, with what it is about. */ +internal fun expressionProblemText(reason: String, detail: String): String { + val text = graphText("expression.$reason") + return if (detail.isBlank()) text else "$text: $detail" +} 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 new file mode 100644 index 000000000..220c2265e --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphEditor.kt @@ -0,0 +1,409 @@ +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.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 + +/** + * Editor of material file: nodes with pins on a canvas, links drawn from pin to pin, and + * what each node computes drawn right on it. + */ +@Composable +internal fun ShaderGraphEditor(file: HollowIdeOpenFile) { + val document = file.document as HollowIdeShaderGraphDocument + val state = remember(document) { document.editorState { ShaderGraphEditorState() } } + val view = state.view + 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>() } + + LaunchedEffect(document.revision) { + file.updateDirty(document.isModified) + if (!document.isModified) return@LaunchedEffect + delay(AutoSaveDelayMillis.milliseconds) + if (document.isModified) file.save() + } + LaunchedEffect(document, document.isModified) { + if (document.isModified) return@LaunchedEffect + val location = VfxGraphMaterials.locationOf(file.path) + RenderSystem.recordRenderCall { VfxGraphMaterials.changed(location) } + } + LaunchedEffect(previews) { + while (true) { + withFrameNanos { } + val error = previews.error + if (error != previewError) previewError = error + } + } + + val graph = document.graph + val kinds = ShaderNodeTypes.revision + val code = remember(document.revision, kinds) { ShaderGraphCompiler.compile(graph) } + val diagnostics = code.diagnostics + listOfNotNull(previewError) + val problems = diagnostics.filter { it.node != null }.groupBy { it.node!! } + previews.show(graph, document.revision to kinds) + + val selection = state.selection + SideEffect { state.diagnostics = diagnostics } + PublishInspector(source = "shadergraph-${file.path}", key = selection) { + shaderGraphInspectorTarget(document, selection, state) + } + + val boxes = LinkedHashMap() + graph.nodes.forEach { node -> + ShaderNodeTypes.of(node.type)?.let { boxes[node.id] = ShaderNodeLayout.of(graph, node, it, code.types) } + } + val pins = boxes.values.flatMap { it.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) { + state.selection = next + } + + 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) } + + /** 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. + */ + fun offerNodeFor(pending: PendingLink) { + val graphX = view.toGraphX(pending.x) + val graphY = view.toGraphY(pending.y) + val items = if (pending.fromOutput) { + val carried = code.types.output(pending.node, pending.pin) ?: ShaderType.FLOAT + addNodeItems({ 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 + addNodeItems({ 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) + } + + /** + * 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 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 -> 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 + dragOrigins.clear() + document.endGesture() + } + + fun deleteSelection() { + val index = selection.link + if (index != null) { + document.edit { it.withoutLinkAt(index) } + } else { + val removable = selection.nodes.filter { boxes[it]?.kind?.master == null }.toSet() + if (removable.isEmpty()) return + document.edit { it.withoutNodes(removable) } + } + select(ShaderGraphSelection.None) + } + + fun duplicateSelection() { + val copied = selection.nodes.filter { boxes[it]?.kind?.master == null }.toSet() + if (copied.isEmpty()) return + val (next, copies) = document.graph.withDuplicates(copied) + document.edit { next } + select(ShaderGraphSelection(copies.toSet())) + } + + fun handleKey(input: UiKeyInput): Boolean { + when { + input.key == GLFW.GLFW_KEY_DELETE -> deleteSelection() + input.control && input.key == GLFW.GLFW_KEY_D -> duplicateSelection() + input.control && input.key == GLFW.GLFW_KEY_A -> select(ShaderGraphSelection(boxes.keys.toSet())) + input.control && input.key == GLFW.GLFW_KEY_Z && !input.shift -> document.undo() + input.control && (input.key == GLFW.GLFW_KEY_Y || input.shift && input.key == GLFW.GLFW_KEY_Z) -> document.redo() + input.key == GLFW.GLFW_KEY_ESCAPE -> if (link != null) { + link = null + document.endGesture() + } else { + select(ShaderGraphSelection.None) + } + + else -> return false + } + return true + } + + 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 -> select(ShaderGraphSelection.of(node)) + } + dragOrigins.clear() + state.selection.nodes.forEach { id -> document.graph.node(id)?.let { dragOrigins[id] = it.x to it.y } } + document.beginGesture() + } + + override fun dragNode(node: String, gesture: GraphNodeGesture) { + if (link != null || dragOrigins.isEmpty()) return + document.edit { graph -> + graph.withNodesAt(dragOrigins.mapValues { (_, at) -> at.first + gesture.graphDeltaX to at.second + gesture.graphDeltaY }) + } + 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 = boxes[node] ?: return + menu = GraphMenu( + screenX, screenY, + nodeItems( + box.node, box.kind, + onPreview = { shown -> document.edit { it.withPreview(node, shown) } }, + onDuplicate = ::duplicateSelection, + onDelete = ::deleteSelection, + ), + ) + } + + 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) } + + override fun setOption(node: String, option: String, value: String) = + document.edit { it.withOption(node, option, value) } + + override fun openMenu(screenX: Float, screenY: Float, items: List) { + menu = GraphMenu(screenX, screenY, items) + } + + override fun beginGesture() = document.beginGesture() + override fun endGesture() = document.endGesture() + } + + Box( + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).style(ShaderGraphStylesheet).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) + }, + minimap = boxes.values.map { box -> + GraphMiniMapItem( + box.rect, if (box.node.id in selection) GraphSelectedColor else GraphLinkColor, box.kind.title() + ) + }, + onBackgroundClick = { + menu = null + select(ShaderGraphSelection.None) + }, + onEdgeClick = { index -> select(ShaderGraphSelection.ofLink(index as Int)) }, + onContextMenu = { pointer, edge -> + menu = if (edge is Int) { + select(ShaderGraphSelection.ofLink(edge)) + GraphMenu( + pointer.screenX, + pointer.screenY, + listOf(UiDropdownItem(graphText("delete_link"), shortcut = "Del") { + document.edit { it.withoutLinkAt(edge) } + select(ShaderGraphSelection.None) + }) + ) + } else { + GraphMenu( + pointer.screenX, + pointer.screenY, + addNodeItems { kind -> addNode(kind, pointer.graphX, pointer.graphY) }) + } + }, + onRelease = { + finishLink() + finishDrag() + }, + onSelectArea = { area, modifiers -> + val covered = boxes.values.filter { it.rect.intersects(area) }.map { it.node.id }.toSet() + val additive = modifiers and (GLFW.GLFW_MOD_SHIFT or GLFW.GLFW_MOD_CONTROL) != 0 + select(ShaderGraphSelection(if (additive) selection.nodes + covered else covered)) + }, + onKey = ::handleKey, + ) { + boxes.values.sortedBy { it.node.id in selection }.forEach { box -> + key(box.node.id) { + ShaderNodeView( + graph = graph, + box = box, + types = code.types, + view = view, + selected = box.node.id in selection, + problem = problems[box.node.id]?.joinToString("\n") { problemText(it) }, + previews = previews, + actions = 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 }, + ) + } + } +} + +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/ShaderGraphFields.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphFields.kt new file mode 100644 index 000000000..e970300ab --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphFields.kt @@ -0,0 +1,129 @@ +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.ShaderGraph +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.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark + +/** What the fields of a node change, and the menus they open; the editor answers them. */ +internal interface ShaderFieldActions { + fun setValue(node: String, pin: String, values: List) + fun setOption(node: String, option: String, value: String) + fun openMenu(screenX: Float, screenY: Float, items: List) + fun beginGesture() + fun endGesture() +} + +/** One choice of a node, edited in place: a menu of values, a box to tick, or text. */ +@Composable +internal fun OptionField( + graph: ShaderGraph, + node: ShaderGraphNode, + option: ShaderOptionSpec, + actions: ShaderFieldActions, +) { + val current = node.options[option.name] ?: option.default + val id = "sg-option-${node.id}-${option.name}" + when (option.kind) { + ShaderOptionKind.TOGGLE -> Row( + tags = listOf("sg-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( + node.id, + option.name, + (current != "true").toString() + ) + event.consume() + }, + ) { + Checkbox( + checked = current == "true", + id = "$id-box", + tags = listOf("sg-checkbox"), + onCheckedChange = { checked -> actions.setOption(node.id, option.name, checked.toString()) }, + ) + Text(optionTitle(option), tags = listOf("sg-pin-label")) + } + + ShaderOptionKind.CHOICE -> { + Text( + optionTitle(option), + tags = listOf("sg-pin-label"), + modifier = Modifier.size(0.px, UiLength.Auto).grow(1f) + ) + Chip(id, optionValueTitle(option, current)) { x, y -> + actions.openMenu(x, y, choiceItems(option, current) { actions.setOption(node.id, option.name, it) }) + } + } + + ShaderOptionKind.PROPERTY -> { + val names = graph.properties.map { it.name } + Chip(id, current.ifEmpty { graphText("pick_property") }) { x, y -> + val items = if (names.isEmpty()) { + listOf(UiDropdownItem(graphText("no_properties"), enabled = false)) + } else { + names.map { name -> + UiDropdownItem(name, mark = UiDropdownMark.RADIO, checked = name == current) { + actions.setOption(node.id, option.name, name) + } + } + } + actions.openMenu(x, y, items) + } + } + + ShaderOptionKind.TEXT -> { + Text( + optionTitle(option), + tags = listOf("sg-pin-label"), + modifier = Modifier.size(0.px, UiLength.Auto).grow(1f) + ) + TextField( + value = current, + id = id, + fontSize = 8f, + tags = listOf("sg-field"), + modifier = Modifier.size(FieldsWidth.px, FieldHeight.px), + onChange = { actions.setOption(node.id, option.name, it) }, + ) + } + + ShaderOptionKind.EXPRESSION -> TextField( + value = current, + id = id, + fontSize = 9f, + syntaxHighlighter = ShaderExpressionEditing.highlighter, + completionContributor = ShaderExpressionEditing.completions, + diagnostics = ShaderExpressionEditing.diagnostics(current), + tags = listOf("sg-field", "sg-expression"), + modifier = Modifier.size(0.px, ExpressionHeight.px).grow(1f), + onChange = { actions.setOption(node.id, option.name, it) }, + ) + } +} + +/** What a choice is set to, which opens its menu where it was pressed. */ +@Composable +private fun Chip(id: String, text: String, onOpen: (Float, Float) -> Unit) { + Text( + text, + id = id, + tags = listOf("sg-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) + event.consume() + }, + ) +} + +/** How wide the fields of a row are together; the name takes what is left. */ +internal const val FieldsWidth = 100f +internal const val FieldHeight = 14f +private const val ExpressionHeight = 18f 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 new file mode 100644 index 000000000..eeba46b53 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphInspector.kt @@ -0,0 +1,191 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.client.shadergraph.* +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeShaderGraphDocument +import ru.hollowhorizon.hollowengine.client.ui.inspector.* +import ru.hollowhorizon.hollowengine.client.utils.lang + +private const val GraphIcon = "hollowengine:textures/gui/icons/files/shader.svg" +private const val RemoveIcon = "hollowengine:textures/gui/icons/remove.svg" + +/** + * What the inspector shows for [selection]: the one node selected, or the graph with its properties, + * preview and problems. The inspector keeps a target for as long as its id stays the same, so what it + * shows is read from the document and from [state] as it draws, not taken when the target is made. + */ +internal fun shaderGraphInspectorTarget( + document: HollowIdeShaderGraphDocument, + selection: ShaderGraphSelection, + state: ShaderGraphEditorState, +): InspectorTarget { + val selected = selection.single?.let(document.graph::node) + val selectedKind = selected?.let { ShaderNodeTypes.of(it.type) } + if (selected != null && selectedKind != null) { + return InspectorTarget( + id = "shadergraph-node-${selected.id}", title = selectedKind.title(), subtitle = selected.id, icon = GraphIcon + ) { + val node = document.graph.node(selected.id) + val kind = node?.let { ShaderNodeTypes.of(it.type) } + if (node != null && kind != null) NodeFields(document, node, kind) + } + } + return InspectorTarget(id = "shadergraph", title = graphText("graph"), icon = GraphIcon) { + GraphFields(document, state.diagnostics) + } +} + +@Composable +private fun NodeFields(document: HollowIdeShaderGraphDocument, node: ShaderGraphNode, kind: ShaderNodeType) { + if (kind.options.isNotEmpty()) { + Section(graphText("options")) { + 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}" + when (option.kind) { + ShaderOptionKind.CHOICE -> { + Label(optionTitle(option)) + Pills(option.values, current, { optionValueTitle(option, it) }, ::set) + } + + ShaderOptionKind.TOGGLE -> ToggleRow(optionTitle(option), current == "true") { set(it.toString()) } + ShaderOptionKind.TEXT -> TextRow(optionTitle(option), current, id = id, onChange = ::set) + ShaderOptionKind.EXPRESSION -> TextRow( + optionTitle(option), + current, + id = id, + completions = ShaderExpressionEditing.completions, + highlighter = ShaderExpressionEditing.highlighter, + diagnostics = ShaderExpressionEditing.diagnostics(current), + onChange = ::set, + ) + + ShaderOptionKind.PROPERTY -> { + Label(optionTitle(option)) + val names = document.graph.properties.map { it.name } + if (names.isEmpty()) Hint(graphText("no_properties")) else Pills(names, current, { it }, ::set) + } + } + } + } + } + val unlinked = kind.inputs(node).filter { pin -> + document.graph.linkInto( + node.id, pin.name + ) == null && pin.fallback == null && pin.type.fixed != ShaderType.TEXTURE + } + if (unlinked.isNotEmpty()) { + Section(graphText("inputs")) { + unlinked.forEach { pin -> ValueRow(document, node, pin) } + } + } + ToggleRow(graphText("show_preview"), kind.showsPreview(node)) { shown -> + document.edit { + it.withPreview( + node.id, shown + ) + } + } +} + +/** One number per component of what an unlinked input holds. */ +@Composable +private fun ValueRow(document: HollowIdeShaderGraphDocument, node: ShaderGraphNode, pin: ShaderPinSpec) { + val values = node.values[pin.name]?.takeIf { it.isNotEmpty() } ?: pin.default.ifEmpty { listOf(0f) } + val width = pin.type.fixed?.width ?: values.size.coerceIn(1, 4) + Label(pin.name) + Row(tags = listOf("insp-input-row")) { + repeat(width) { component -> + NumberInput( + path = "sg-value-${node.id}-${pin.name}-$component", + value = values.getOrElse(component) { values.last() }.toDouble(), + whole = false, + modifier = Modifier.size(0.px).grow(1f), + ) { typed -> + val next = (0 until width).map { values.getOrElse(it) { values.last() } }.toMutableList() + next[component] = typed.toFloat() + document.edit { it.withValue(node.id, pin.name, next) } + } + } + } +} + +@Composable +private fun GraphFields(document: HollowIdeShaderGraphDocument, problems: List) { + val preview = document.graph.preview + Section(graphText("preview")) { + Label(graphText("preview_mesh")) + Pills(ShaderPreviewMesh.entries, preview.mesh, { graphText("mesh.${it.name.lowercase()}") }) { mesh -> + document.edit { it.withPreviewSettings(preview.copy(mesh = mesh)) } + } + ToggleRow(graphText("preview_rotate"), preview.rotate) { rotate -> + document.edit { it.withPreviewSettings(preview.copy(rotate = rotate)) } + } + TextRow(graphText("preview_texture"), preview.texture, id = "sg-preview-texture") { texture -> + document.edit { it.withPreviewSettings(preview.copy(texture = texture)) } + } + } + Section(graphText("properties")) { + document.graph.properties.forEachIndexed { index, property -> PropertyFields(document, index, property) } + InspectorButton(graphText("add_property")) { + document.edit { it.withProperty(ShaderGraphProperty(it.freePropertyName(), ShaderType.FLOAT, listOf(0f))) } + } + } + Section(graphText("problems")) { + if (problems.isEmpty()) Hint(graphText("no_problems")) + problems.forEach { problem -> Hint(listOfNotNull(problem.node, problemText(problem)).joinToString(": ")) } + } +} + +/** + * The fields of one property. They are named after its place in the list rather than after the + * property, so renaming it keeps the field being typed in. + */ +@Composable +private fun PropertyFields(document: HollowIdeShaderGraphDocument, index: Int, property: ShaderGraphProperty) { + 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)) } + } + InspectorIconButton(RemoveIcon, graphText("remove_property"), tags = listOf("insp-inline-icon", "danger")) { + document.edit { it.withoutProperty(property.name) } + } + } + 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 -> + change(property.copy(texture = texture)) + } + } else { + Row(tags = listOf("insp-input-row")) { + repeat(property.type.width) { component -> + val values = property.default.ifEmpty { listOf(0f) } + NumberInput( + path = "sg-property-$index-$component", + value = values.getOrElse(component) { values.last() }.toDouble(), + whole = false, + modifier = Modifier.size(0.px).grow(1f), + ) { typed -> + val next = + (0 until property.type.width).map { values.getOrElse(it) { values.last() } }.toMutableList() + next[component] = typed.toFloat() + change(property.copy(default = next)) + } + } + } + } +} + +/** What a problem of the compiler says, in words. */ +internal fun problemText(diagnostic: ShaderDiagnostic): String { + val text = "hollowengine.gui.shadergraph.problem.${diagnostic.problem.name.lowercase()}".lang + return when { + diagnostic.reason != null -> "$text: ${expressionProblemText(diagnostic.reason, diagnostic.detail)}" + diagnostic.problem == ShaderProblem.GLSL_ERROR -> "$text\n${diagnostic.detail}" + diagnostic.detail.isBlank() -> text + else -> "$text (${diagnostic.detail})" + } +} 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 new file mode 100644 index 000000000..b72422d86 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphLayout.kt @@ -0,0 +1,121 @@ +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.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 + val height: Float + + data class Output(val name: String, override val top: Float) : ShaderNodeRow { + override val height get() = ShaderNodeLayout.ROW + } + + data class Option( + val name: String, + val kind: ShaderOptionKind, + override val top: Float, + override val height: Float, + ) : ShaderNodeRow + + /** An input that takes a link, or what it falls back to, or a texture: its name, and no field. */ + data class Input(val pin: ShaderPinSpec, override val top: Float) : ShaderNodeRow { + override val height get() = ShaderNodeLayout.ROW + } + + /** An input with nothing linked to it: its name and a field per component of its value. */ + data class Value(val pin: ShaderPinSpec, val components: Int, override val top: Float) : ShaderNodeRow { + override val height get() = ShaderNodeLayout.ROW + } +} + +/** Where everything of one node sits, in graph units from the corner of the node. */ +internal class ShaderNodeBox( + val node: ShaderGraphNode, + val kind: ShaderNodeType, + val width: Float, + val height: Float, + val rows: List, + val preview: Float, + val previewTop: Float, +) { + val rect: GraphRect get() = GraphRect(node.x, node.y, width, height) + + val pins: List + get() = 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.Option -> null + } + } +} + +internal object ShaderNodeLayout { + const val WIDTH = 176f + const val OUTPUT_WIDTH = 196f + const val HEADER = 22f + const val ROW = 18f + const val OPTION_ROW = 20f + const val EXPRESSION_ROW = 24f + const val PADDING = 5f + const val PREVIEW = 116f + const val OUTPUT_PREVIEW = 176f + const val PIN = 10f + + fun of(graph: ShaderGraph, node: ShaderGraphNode, kind: ShaderNodeType, types: ShaderGraphTypes): ShaderNodeBox { + val width = if (kind.master != null) OUTPUT_WIDTH else WIDTH + val rows = ArrayList() + var y = HEADER + PADDING + kind.outputs.forEach { output -> + rows += ShaderNodeRow.Output(output.name, y) + y += ROW + } + kind.options.forEach { option -> + val height = if (option.kind == ShaderOptionKind.EXPRESSION) EXPRESSION_ROW else OPTION_ROW + rows += ShaderNodeRow.Option(option.name, option.kind, y, height) + y += height + } + kind.inputs(node).forEach { pin -> + val linked = graph.linkInto(node.id, pin.name) != null + rows += if (linked || pin.fallback != null || pin.type.fixed == ShaderType.TEXTURE) { + ShaderNodeRow.Input(pin, y) + } else { + ShaderNodeRow.Value(pin, components(node, pin, types), y) + } + y += ROW + } + y += PADDING + val preview = if (kind.showsPreview(node)) (if (kind.master != null) OUTPUT_PREVIEW else PREVIEW) else 0f + val previewTop = y + if (preview > 0f) y += preview + PADDING + return ShaderNodeBox(node, kind, width, y, rows, preview, previewTop) + } + + private fun components(node: ShaderGraphNode, pin: ShaderPinSpec, types: ShaderGraphTypes): Int { + pin.type.fixed?.let { return it.width } + val stored = node.values[pin.name]?.size ?: pin.default.size + val resolved = types.input(node.id, pin.name)?.width ?: 1 + return maxOf(stored, resolved).coerceIn(1, 4) + } +} + +internal fun ShaderType.color(): UiColor = when (this) { + ShaderType.FLOAT -> UiColor(0.62f, 0.66f, 0.74f) + ShaderType.VEC2 -> UiColor(0.47f, 0.80f, 0.56f) + ShaderType.VEC3 -> UiColor(0.96f, 0.82f, 0.38f) + ShaderType.VEC4 -> UiColor(0.90f, 0.52f, 0.78f) + ShaderType.TEXTURE -> UiColor(0.95f, 0.55f, 0.35f) +} + +internal val GraphLinkColor = UiColor(0.60f, 0.64f, 0.72f) +internal val GraphSelectedColor = UiColor(0.43f, 0.61f, 0.86f) + +internal fun ShaderType.tag(): String = "sg-type-${name.lowercase()}" 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 new file mode 100644 index 000000000..b4eef9d09 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphMenus.kt @@ -0,0 +1,90 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +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. + */ +internal fun addNodeItems( + accepts: (ShaderNodeType) -> Boolean = { true }, + onPick: (ShaderNodeType) -> Unit, +): List = ShaderNodeCategory.entries.filter { it != ShaderNodeCategory.OUTPUT }.mapNotNull { category -> + val kinds = ShaderNodeTypes.all.filter { it.category == category && it.master == null && accepts(it) } + if (kinds.isEmpty()) return@mapNotNull null + UiDropdownItem( + graphText("category.${category.name.lowercase()}"), + children = kinds.mapIndexed { index, kind -> + UiDropdownItem(kind.title(), separatorBefore = index > 0 && kind.group != kinds[index - 1].group) { + onPick( + kind + ) + } + }, + ) +} + +/** What the menu of a node offers: its preview, and unless it is the output, a copy and removal. */ +internal fun nodeItems( + node: ShaderGraphNode, + kind: ShaderNodeType, + onPreview: (Boolean) -> Unit, + onDuplicate: () -> Unit, + onDelete: () -> Unit, +): List = buildList { + val shown = kind.showsPreview(node) + add( + UiDropdownItem( + graphText("show_preview"), + mark = UiDropdownMark.CHECKBOX, + checked = shown + ) { onPreview(!shown) }) + if (kind.master != null) return@buildList + add(UiDropdownItem(graphText("duplicate"), shortcut = "Ctrl+D", separatorBefore = true, onClick = onDuplicate)) + add(UiDropdownItem(graphText("delete_node"), shortcut = "Del", onClick = onDelete)) +} + +/** 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 -> + UiDropdownItem( + optionValueTitle(option, value), + mark = UiDropdownMark.RADIO, + checked = value == current + ) { onPick(value) } + } + +/** The first input of a node of this kind a value of [type] can be linked into, one of that very type first. */ +internal fun ShaderNodeType.inputFor(type: ShaderType, node: ShaderGraphNode = prototype): ShaderPinSpec? { + val pins = inputs(node) + return pins.firstOrNull { it.type.fixed == type } ?: pins.firstOrNull { it.type.accepts(type) } +} + +/** The first output of this kind that can be linked into a pin of [pin]. */ +internal fun ShaderNodeType.outputFor(pin: ShaderPinType): ShaderOutputSpec? = outputs.firstOrNull { output -> + output.type.fixed?.let(pin::accepts) ?: (output.typeOf != null || pin != ShaderPinType.TEXTURE) +} + +/** The translated name of an option, or the option made readable. */ +internal fun optionTitle(option: ShaderOptionSpec): String = + option.titleKey.lang.takeIf { it != option.titleKey } ?: readable(option.name) + +internal fun optionValueTitle(option: ShaderOptionSpec, value: String): String { + val key = option.valueKey(value) + return key.lang.takeIf { it != key } ?: readable(value) +} + +private fun readable(name: String): String = name.replace('_', ' ').replaceFirstChar { it.uppercase() } + +/** The translated title of a kind, or its id made readable when nothing translates it. */ +internal fun ShaderNodeType.title(): String { + val translated = titleKey.lang + if (translated != titleKey) return translated + return readable(id.substringAfterLast('/')) +} 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 new file mode 100644 index 000000000..b40ce6a10 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphNodeView.kt @@ -0,0 +1,170 @@ +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.widgets.tooltipOnHover + +internal const val ShaderGraphCanvasId = "shader-graph-canvas" + +/** What a node and its pins do when pressed and dragged. */ +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 pressPin(pin: ShaderPin, screenX: Float, screenY: Float) + fun dragPin(canvasX: Float, canvasY: Float, screenX: Float, screenY: Float) + fun releasePin() +} + +/** + * One node on the canvas, laid out as [box] says: the title, a row per output, option and input, the + * preview, and the pins on its edges. + */ +@Composable +internal fun ShaderNodeView( + graph: ShaderGraph, + box: ShaderNodeBox, + types: ShaderGraphTypes, + view: GraphViewState, + selected: Boolean, + 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()}", "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)) { + Row( + tags = listOf("sg-node-head"), + modifier = Modifier.position(HeaderInset.px, HeaderInset.px) + .size((box.width - HeaderInset * 2).px, (ShaderNodeLayout.HEADER - HeaderInset).px) + .alignItems(vertical = UiAlign.CENTER) + .let { if (problem != null) it.tooltipOnHover(problem) else it }, + ) { + Text( + kind.title(), + tags = listOf("graph-node-title"), + modifier = Modifier.size(0.px, UiLength.Auto).grow(1f) + ) + } + box.rows.forEach { row -> NodeRow(graph, box, row, types, actions) } + if (box.preview > 0f) { + Box( + tags = listOf("sg-node-preview"), + modifier = Modifier.position(((box.width - box.preview) / 2f).px, box.previewTop.px) + .size(box.preview.px, box.preview.px) + .drawBehind(key = node.id) { drawTexture(bounds, { previews.texture(node.id) }, flipY = true) }, + ) + } + 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) + } + } + } +} + +@Composable +private fun NodeRow( + graph: ShaderGraph, + box: ShaderNodeBox, + row: ShaderNodeRow, + types: ShaderGraphTypes, + actions: ShaderNodeActions, +) { + val node = box.node + Row( + tags = listOf("sg-node-row"), + modifier = Modifier.position(0.px, row.top.px).size(box.width.px, row.height.px).padding(RowPadding.px, 0.px) + .alignItems(vertical = UiAlign.CENTER), + ) { + when (row) { + is ShaderNodeRow.Output -> { + Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) + Text(row.name, tags = listOf("sg-pin-label", "output")) + } + + is ShaderNodeRow.Input -> { + Text(row.pin.name, tags = listOf("sg-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")) + } + + is ShaderNodeRow.Value -> { + val values = + node.values[row.pin.name]?.takeIf { it.isNotEmpty() } ?: row.pin.default.ifEmpty { listOf(0f) } + ValueFields(node, row.pin, values, row.components, actions) + } + + is ShaderNodeRow.Option -> box.kind.option(row.name) + ?.let { option -> OptionField(graph, node, option, actions) } + } + } +} + +/** A pin on the edge of its node, in the color of what it carries, filled once something is linked. */ +@Composable +private 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() + }, + ) +} + +/** How far the title bar sits inside the frame of the node, which its corners follow. */ +private const val HeaderInset = 2f +private const val RowPadding = 8f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphPreviews.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphPreviews.kt new file mode 100644 index 000000000..b20f5cb11 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphPreviews.kt @@ -0,0 +1,256 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +import com.mojang.blaze3d.pipeline.TextureTarget +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.VertexBuffer +import com.mojang.blaze3d.vertex.VertexFormat +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import org.joml.Matrix4f +import org.lwjgl.opengl.GL20 +import org.lwjgl.opengl.GL33 +import org.lwjgl.system.MemoryStack +import ru.hollowhorizon.hollowengine.client.shadergraph.* +import ru.hollowhorizon.hollowengine.client.ui.render.withCullStatePreserved +import ru.hollowhorizon.hollowengine.client.ui.render.withOwnRenderTarget +import ru.hollowhorizon.hollowengine.common.utils.rl +import java.util.concurrent.atomic.AtomicInteger + +/** + * What every node computes, drawn into a small texture per node. + * + * The whole graph is one program: a uniform picks whose value a draw shows, so an edit compiles once + * however many nodes there are, and a value typed on a node is a uniform too, so changing it compiles + * nothing. A node that reads the shape of what it is on, and the output node, are drawn on the mesh + * the graph picked; the rest on a flat quad. + */ +internal class ShaderGraphPreviews { + /** A graph to draw, and what tells one version of it from the next. */ + private class Frame(val graph: ShaderGraph, val revision: Any) + + @Volatile + private var latest: Frame? = null + + /** Why the program did not build, for the editor to show; set on the render thread. */ + @Volatile + var error: ShaderDiagnostic? = null + private set + + private val name = "preview_${Counter.incrementAndGet()}" + private val meshes = ShaderPreviewMeshes() + private val started = System.nanoTime() + private var program: ShaderInstance? = null + private var programSource: String? = null + private var code: ShaderPreviewCode? = null + private var drawnRevision: Any? = null + private var animated = false + private val targets = HashMap() + + /** Called from composition with the graph as it is now. */ + fun show(graph: ShaderGraph, revision: Any) { + if (latest?.revision != revision) latest = Frame(graph, revision) + } + + /** The texture of [node]'s preview, or 0 while there is none; read while the UI draws. */ + fun texture(node: String): Int = targets[node]?.colorTextureId ?: 0 + + /** Draws what changed; runs on the render thread, inside a UI draw. */ + fun render() { + val frame = latest ?: return + val changed = frame.revision != drawnRevision + if (!changed && !animated) return + drawnRevision = frame.revision + if (changed) rebuild(frame.graph) + val shader = program ?: return + val compiled = code ?: return + + val blend = GL33.glIsEnabled(GL33.GL_BLEND) + val depth = GL33.glIsEnabled(GL33.GL_DEPTH_TEST) + withOwnRenderTarget { + withCullStatePreserved { + RenderSystem.disableBlend() + RenderSystem.disableDepthTest() + val master = frame.graph.nodes.firstOrNull { ShaderNodeTypes.of(it.type)?.master != null }?.id + compiled.previewIndex.forEach { (node, index) -> + val pixels = if (node == master) OUTPUT_PIXELS else PIXELS + val target = target(node, pixels) + target.setClearColor(0f, 0f, 0f, 0f) + target.clear(Minecraft.ON_OSX) + target.bindWrite(true) + val mesh = frame.graph.preview.mesh.takeIf { node in compiled.spatial } ?: ShaderPreviewMesh.QUAD + draw(shader, frame.graph, compiled, mesh, index, pixels) + } + } + } + if (depth) RenderSystem.enableDepthTest() + if (blend) RenderSystem.enableBlend() + } + + private fun target(node: String, pixels: Int): TextureTarget { + val existing = targets[node] + if (existing != null && existing.width == pixels) return existing + existing?.destroyBuffers() + return TextureTarget(pixels, pixels, false, Minecraft.ON_OSX).also { targets[node] = it } + } + + private fun draw( + shader: ShaderInstance, + graph: ShaderGraph, + compiled: ShaderPreviewCode, + mesh: ShaderPreviewMesh, + index: Int, + pixels: Int, + ) { + val seconds = (System.nanoTime() - started) / 1_000_000_000f + val flat = mesh == ShaderPreviewMesh.QUAD + val camera = if (flat) PreviewCamera(Matrix4f(), Matrix4f(), Matrix4f()) else camera(seconds, graph.preview.rotate, mesh) + if (flat) RenderSystem.disableCull() else RenderSystem.enableCull() + + val buffer = meshes.of(mesh) + buffer.bind() + shader.setDefaultUniforms(VertexFormat.Mode.TRIANGLES, camera.view, camera.projection, Minecraft.getInstance().window) + shader.safeGetUniform("PreviewModel").set(camera.model) + shader.safeGetUniform("PreviewNode").set(index) + shader.safeGetUniform("PreviewTime").set(seconds) + shader.safeGetUniform("PreviewFlat").set(if (flat) 1f else 0f) + shader.safeGetUniform("PreviewPixels").set(pixels.toFloat()) + shader.setSampler("Sampler0", textureAt(graph.preview.texture)) + graph.properties.forEach { property -> + if (property.type == ShaderType.TEXTURE) { + if (property.texture.isNotBlank()) shader.setSampler( + propertyUniform(property.name), + textureAt(property.texture) + ) + } else { + val values = FloatArray(property.type.width) { + property.default.getOrElse(it) { + property.default.lastOrNull() ?: 0f + } + } + shader.safeGetUniform(propertyUniform(property.name)).set(values) + } + } + shader.apply() + uploadValues(shader, graph, compiled.values) + buffer.draw() + shader.clear() + VertexBuffer.unbind() + } + + /** The values typed on the nodes, read from the graph as it is now; the program only knows their slots. */ + private fun uploadValues(shader: ShaderInstance, graph: ShaderGraph, slots: List) { + if (slots.isEmpty()) return + val location = GL20.glGetUniformLocation(shader.id, "PreviewValues") + if (location < 0) return + MemoryStack.stackPush().use { stack -> + val buffer = stack.mallocFloat(slots.size * 4) + slots.forEach { slot -> + val node = graph.node(slot.node) + val pin = node?.let { + ShaderNodeTypes.of(it.type)?.codeInputs(it)?.firstOrNull { pin -> pin.name == slot.pin } + } + val values = + node?.values?.get(slot.pin)?.takeIf { it.isNotEmpty() } ?: pin?.default?.ifEmpty { null } ?: listOf( + 0f + ) + repeat(4) { component -> buffer.put(values.getOrElse(component) { values.last() }) } + } + buffer.flip() + GL20.glUniform4fv(location, buffer) + } + } + + /** Where the mesh is in the world, and the camera that looks at it. */ + private class PreviewCamera(val model: Matrix4f, val view: Matrix4f, val projection: Matrix4f) + + private fun camera(seconds: Float, rotate: Boolean, mesh: ShaderPreviewMesh): PreviewCamera { + val angle = if (rotate) seconds * TURN_SPEED else RESTING_ANGLE + val size = if (mesh == ShaderPreviewMesh.CUBE) CUBE_SIZE else 1f + val model = Matrix4f().rotateY(angle).scale(size) + val view = Matrix4f().translate(0f, 0f, -CAMERA_DISTANCE).rotateX(CAMERA_TILT) + val projection = Matrix4f().perspective(Math.toRadians(FIELD_OF_VIEW).toFloat(), 1f, 0.1f, 20f) + return PreviewCamera(model, view, projection) + } + + private fun rebuild(graph: ShaderGraph) { + val compiled = ShaderGraphCompiler.compilePreview(graph) + val source = ShaderGraphTemplates.preview(compiled) + val vertexSource = ShaderGraphTemplates.previewVertex(compiled) + code = compiled + animated = + ShaderInput.TIME.glsl in compiled.statements.text || ShaderInput.TIME.glsl in compiled.vertex.text || graph.preview.rotate && graph.preview.mesh != ShaderPreviewMesh.QUAD && compiled.spatial.any { it in compiled.previewIndex } + (targets.keys - compiled.previewIndex.keys).forEach { targets.remove(it)?.destroyBuffers() } + if (vertexSource + source == programSource) return + + program?.close() + program = null + programSource = vertexSource + source + val stage = "${name}_${Integer.toHexString((vertexSource + source).hashCode())}" + val built = ShaderGraphPrograms.create( + name = stage, + json = ShaderGraphPrograms.json( + vertex = ShaderGraphPrograms.stage(stage), + fragment = ShaderGraphPrograms.stage(stage), + attributes = listOf("Position", "Color", "UV0", "UV1", "UV2", "Normal"), + samplers = listOf("Sampler0") + ShaderGraphPrograms.propertySamplers(graph.properties), + uniforms = listOf( + ShaderGraphUniform("ModelViewMat", "matrix4x4", IDENTITY), + ShaderGraphUniform("ProjMat", "matrix4x4", IDENTITY), + ShaderGraphUniform("PreviewModel", "matrix4x4", IDENTITY), + ShaderGraphUniform("PreviewNode", "int", listOf(0f)), + ShaderGraphUniform("PreviewTime", "float", listOf(0f)), + ShaderGraphUniform("PreviewFlat", "float", listOf(1f)), + ShaderGraphUniform("PreviewPixels", "float", listOf(PIXELS.toFloat())), + ) + ShaderGraphPrograms.propertyUniforms(graph.properties), + ), + stages = mapOf("vsh" to vertexSource, "fsh" to source), + format = meshes.format, + ) + program = built.getOrNull() + error = built.exceptionOrNull()?.let { diagnose(it.message.orEmpty(), source, compiled) } + } + + /** + * What the driver said, pinned to the node whose statement it points at when it names a line of + * the graph's code, which drivers write as `0(12)` or `0:12`. + */ + private fun diagnose(message: String, source: String, compiled: ShaderPreviewCode): ShaderDiagnostic { + val first = ShaderGraphTemplates.previewStatementLines(source, compiled) + val line = ErrorLine.find(message)?.groupValues?.get(1)?.toIntOrNull() + val node = if (first != null && line != null) compiled.statementNodes.getOrNull(line - first) else null + val text = + message.lineSequence().firstOrNull { "error" in it.lowercase() && ErrorLine.containsMatchIn(it) }?.trim() + ?: message + return ShaderDiagnostic(ShaderProblem.GLSL_ERROR, node, text.take(ERROR_LENGTH)) + } + + /** Frees the program, the meshes and the textures; GL objects, so on the render thread. */ + fun release() { + program?.close() + program = null + programSource = null + drawnRevision = null + meshes.release() + targets.values.forEach(TextureTarget::destroyBuffers) + targets.clear() + } + + private fun textureAt(location: String): Int = Minecraft.getInstance().textureManager.getTexture(location.rl).id + + private companion object { + const val PIXELS = 128 + const val OUTPUT_PIXELS = 192 + const val TURN_SPEED = 0.6f + const val RESTING_ANGLE = 0.6f + const val CAMERA_DISTANCE = 3.4f + const val CAMERA_TILT = 0.35f + const val CUBE_SIZE = 0.72f + const val FIELD_OF_VIEW = 34.0 + const val ERROR_LENGTH = 400 + val IDENTITY = listOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f) + val ErrorLine = Regex("""\b0[(:](\d+)""") + + /** Programs are cached by name, so every editor gets names of its own. */ + val Counter = AtomicInteger() + } +} 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 new file mode 100644 index 000000000..68281bc62 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphState.kt @@ -0,0 +1,44 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +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. */ +internal data class ShaderGraphSelection(val nodes: Set = emptySet(), val link: Int? = 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) + } +} + +/** 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) + + /** 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 new file mode 100644 index 000000000..b1e2a051f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderGraphValueFields.kt @@ -0,0 +1,201 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +import androidx.compose.runtime.* +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphNode +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPinSpec +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint +import ru.hollowhorizon.hollowengine.client.ui.widgets.ColorPicker +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextInputFilter +import java.util.* +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.sign + +/** + * The value of an unlinked input: its name, then a field per component, or for a color a swatch that + * opens a picker. + */ +@Composable +internal fun ValueFields( + node: ShaderGraphNode, + pin: ShaderPinSpec, + values: List, + components: Int, + actions: ShaderFieldActions, +) { + val shown = (0 until components).map { values.getOrElse(it) { values.lastOrNull() ?: 0f } } + fun set(component: Int, value: Float) = + actions.setValue(node.id, pin.name, shown.toMutableList().also { it[component] = value }) + + Text(pin.name, tags = listOf("sg-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) } + return + } + Row(tags = listOf("sg-fields"), modifier = Modifier.size(FieldsWidth.px, UiLength.Auto)) { + shown.forEachIndexed { component, value -> + NumberField("sg-field-${node.id}-${pin.name}-$component", value, actions) { set(component, it) } + } + } +} + +/** + * A number shown as text that a drag sideways changes, by a hundredth per pixel, a thousandth with + * Shift and a tenth with Ctrl, as one step back. A click without a drag types into it instead; while + * it is typed into, the wheel steps it by a tenth, a hundredth with Shift and one with Ctrl. + * Without [gestures], a drag leaves the steps back to whoever holds one open around the field. + */ +@Composable +internal fun NumberField( + id: String, + value: Float, + actions: ShaderFieldActions, + gestures: Boolean = true, + onChange: (Float) -> Unit, +) { + var editing by remember(id) { mutableStateOf(false) } + val focus = LocalUiFocusRequester.current + val start = remember(id) { floatArrayOf(0f) } + // Whether the left button went down here, and whether it has moved since. + val gesture = remember(id) { booleanArrayOf(false, false) } + + if (editing) { + TypedNumber("$id-edit", value, onChange) { editing = false } + return + } + Text( + formatValue(value), + id = id, + tags = listOf("sg-field", "sg-number"), + modifier = Modifier.size(0.px, FieldHeight.px).grow(1f) + .input(hoverable = true, clickable = true, draggable = true).onPress { event -> + if (event.button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return@onPress + gesture[0] = true + gesture[1] = false + start[0] = value + if (gestures) actions.beginGesture() + event.consume() + }.onDrag { event -> + if (!gesture[0]) return@onDrag + if (abs(event.dragTotalX) >= DRAG_THRESHOLD) gesture[1] = true + if (gesture[1]) onChange(rounded(start[0] + event.dragTotalX * step(event.modifiers, DRAG_STEP))) + event.consume() + }.onRelease { event -> + if (!gesture[0]) return@onRelease + gesture[0] = false + if (gestures) actions.endGesture() + if (!gesture[1]) { + editing = true + focus.request("$id-edit") + } + event.consume() + }, + ) +} + +/** The field a number is typed into; it goes back to text on Enter, on Escape and when it loses focus. */ +@Composable +private fun TypedNumber(id: String, value: Float, onChange: (Float) -> Unit, onDone: () -> Unit) { + var draft by remember(id) { mutableStateOf(formatValue(value)) } + TextField( + value = draft, + id = id, + filter = UiTextInputFilter.DECIMAL, + fontSize = 8f, + tags = listOf("sg-field"), + modifier = Modifier.size(0.px, FieldHeight.px).grow(1f).onUnfocus { onDone() }.onKeyInput { input -> + if (input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER || input.key == GLFW.GLFW_KEY_ESCAPE) { + onDone() + input.consume() + } + }.onScroll { event -> + val next = rounded( + (draft.toFloatOrNull() ?: value) + sign(event.rawScrollY) * step( + event.modifiers, + WHEEL_STEP + ) + ) + draft = formatValue(next) + onChange(next) + event.consume() + }, + onChange = { text -> + draft = text + text.toFloatOrNull()?.let(onChange) + }, + ) +} + +/** + * A color as a swatch; a press opens a picker, with a field per channel under it for values past 1, + * such as a bright emission. Everything done while the picker is open is one step back. + */ +@Composable +private fun ColorSwatch(id: String, values: List, actions: ShaderFieldActions, onChange: (List) -> Unit) { + var anchor by remember(id) { mutableStateOf(null) } + val color = UiColor(values[0], values[1], values[2], values.getOrElse(3) { 1f }) + Box( + id = id, + tags = listOf("sg-swatch"), + modifier = Modifier.size(FieldsWidth.px, FieldHeight.px) + .drawBehind(key = color) { drawRect(bounds, UiPaint.Color(color.clamped()), radius = 2f) } + .input(hoverable = true, clickable = true).onPress { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT && anchor == null) { + actions.beginGesture() + anchor = UiRect(event.x, event.y, 0f, 0f) + } + event.consume() + }, + ) + val open = anchor ?: return + Popup( + anchorBounds = open, + id = "$id-popup", + tags = listOf("sg-color-popup"), + onDismiss = { + anchor = null + actions.endGesture() + }, + ) { + Column(tags = listOf("sg-color-popup-body")) { + ColorPicker( + value = color.clamped(), + showAlpha = values.size == 4, + onValueChange = { picked -> + onChange( + listOf(picked.red, picked.green, picked.blue, picked.alpha).take(values.size).map(::rounded) + ) + }, + ) + Row(tags = listOf("sg-fields")) { + values.forEachIndexed { channel, value -> + NumberField("$id-channel-$channel", value, actions, gestures = false) { typed -> + onChange(values.toMutableList().also { it[channel] = typed }) + } + } + } + } + } +} + +private fun UiColor.clamped() = + UiColor(red.coerceIn(0f, 1f), green.coerceIn(0f, 1f), blue.coerceIn(0f, 1f), alpha.coerceIn(0f, 1f)) + +private fun step(modifiers: Int, base: Float): Float = when { + modifiers and GLFW.GLFW_MOD_SHIFT != 0 -> base / 10f + modifiers and GLFW.GLFW_MOD_CONTROL != 0 -> base * 10f + else -> base +} + +internal fun formatValue(value: Float): String = + if (value == value.toInt().toFloat()) value.toInt().toString() else "%.3f".format(Locale.ROOT, value).trimEnd('0') + +private fun rounded(value: Float): Float = (value * 1000f).roundToInt() / 1000f + +/** Pixels a press has to move before it counts as a drag rather than a click. */ +private const val DRAG_THRESHOLD = 2f +private const val DRAG_STEP = 0.01f +private const val WHEEL_STEP = 0.1f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderPreviewMeshes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderPreviewMeshes.kt new file mode 100644 index 000000000..955cae684 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/shadergraph/ShaderPreviewMeshes.kt @@ -0,0 +1,114 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.shadergraph + +import com.mojang.blaze3d.vertex.BufferBuilder +import com.mojang.blaze3d.vertex.DefaultVertexFormat +import com.mojang.blaze3d.vertex.Tesselator +import com.mojang.blaze3d.vertex.VertexBuffer +import com.mojang.blaze3d.vertex.VertexFormat +import net.minecraft.client.renderer.texture.OverlayTexture +import org.joml.Vector3f +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderPreviewMesh +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * What the previews are drawn on, uploaded once and kept for as long as the editor is open. Every + * mesh faces outward counterclockwise, so with back faces culled a convex one needs no depth buffer. + */ +internal class ShaderPreviewMeshes { + private val meshes = HashMap() + + /** The vertex format of every mesh; the preview program binds its attributes by these names. */ + val format: VertexFormat get() = DefaultVertexFormat.NEW_ENTITY + + fun of(mesh: ShaderPreviewMesh): VertexBuffer = meshes.getOrPut(mesh) { + val builder = Tesselator.getInstance().begin(VertexFormat.Mode.TRIANGLES, format) + when (mesh) { + ShaderPreviewMesh.QUAD -> quad(builder) + ShaderPreviewMesh.SPHERE -> sphere(builder) + ShaderPreviewMesh.CUBE -> cube(builder) + } + VertexBuffer(VertexBuffer.Usage.STATIC).apply { + bind() + upload(builder.buildOrThrow()) + VertexBuffer.unbind() + } + } + + fun release() { + meshes.values.forEach(VertexBuffer::close) + meshes.clear() + } + + /** The whole target, from -1 to 1, facing the camera; drawn without matrices. */ + private fun quad(builder: BufferBuilder) { + val normal = Vector3f(0f, 0f, 1f) + face(builder, Vector3f(0f, 0f, 0f), Vector3f(1f, 0f, 0f), Vector3f(0f, 1f, 0f), normal) + } + + /** A unit sphere, the seam at the back; u goes around it, v from the bottom to the top. */ + private fun sphere(builder: BufferBuilder) { + fun point(stack: Int, slice: Int): Vector3f { + val theta = PI * stack / STACKS + val phi = 2 * PI * slice / SLICES + return Vector3f((sin(theta) * sin(phi)).toFloat(), cos(theta).toFloat(), (sin(theta) * cos(phi)).toFloat()) + } + fun vertex(stack: Int, slice: Int) { + val p = point(stack, slice) + val u = slice.toFloat() / SLICES + val v = 1f - stack.toFloat() / STACKS + builder.addVertex(p.x, p.y, p.z, WHITE, u, v, OverlayTexture.NO_OVERLAY, FULL_BRIGHT, p.x, p.y, p.z) + } + for (stack in 0 until STACKS) { + for (slice in 0 until SLICES) { + vertex(stack, slice) + vertex(stack + 1, slice) + vertex(stack + 1, slice + 1) + vertex(stack, slice) + vertex(stack + 1, slice + 1) + vertex(stack, slice + 1) + } + } + } + + /** + * A cube from -1 to 1, with the whole of the UV square on each face; like every mesh here it spans + * -1 to 1, which is what the position in the object reads, and the camera sizes it to fit. + */ + private fun cube(builder: BufferBuilder) { + listOf( + Triple(Vector3f(0f, 0f, 1f), Vector3f(1f, 0f, 0f), Vector3f(0f, 1f, 0f)), + Triple(Vector3f(0f, 0f, -1f), Vector3f(-1f, 0f, 0f), Vector3f(0f, 1f, 0f)), + Triple(Vector3f(1f, 0f, 0f), Vector3f(0f, 0f, -1f), Vector3f(0f, 1f, 0f)), + Triple(Vector3f(-1f, 0f, 0f), Vector3f(0f, 0f, 1f), Vector3f(0f, 1f, 0f)), + Triple(Vector3f(0f, 1f, 0f), Vector3f(1f, 0f, 0f), Vector3f(0f, 0f, -1f)), + Triple(Vector3f(0f, -1f, 0f), Vector3f(1f, 0f, 0f), Vector3f(0f, 0f, 1f)), + ).forEach { (normal, right, up) -> + face(builder, normal, right, up, normal) + } + } + + /** Two triangles around [center], [right] and [up] from it to the edges, counterclockwise seen from [normal]. */ + private fun face(builder: BufferBuilder, center: Vector3f, right: Vector3f, up: Vector3f, normal: Vector3f) { + fun corner(u: Float, v: Float) { + val x = center.x + right.x * (u * 2f - 1f) + up.x * (v * 2f - 1f) + val y = center.y + right.y * (u * 2f - 1f) + up.y * (v * 2f - 1f) + val z = center.z + right.z * (u * 2f - 1f) + up.z * (v * 2f - 1f) + builder.addVertex(x, y, z, WHITE, u, v, OverlayTexture.NO_OVERLAY, FULL_BRIGHT, normal.x, normal.y, normal.z) + } + corner(0f, 0f) + corner(1f, 0f) + corner(1f, 1f) + corner(0f, 0f) + corner(1f, 1f) + corner(0f, 1f) + } + + private companion object { + const val STACKS = 24 + const val SLICES = 48 + const val WHITE = -1 + const val FULL_BRIGHT = 0xF000F0 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxColorField.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxColorField.kt new file mode 100644 index 000000000..aa4688141 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxColorField.kt @@ -0,0 +1,270 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorButton +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint +import ru.hollowhorizon.hollowengine.client.ui.widgets.ColorPicker +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 ru.hollowhorizon.hollowengine.common.vfx.* + +/** + * Color property: color, gradient over time, or a value per channel. + */ +@Composable +fun VfxColorRow( + label: String, + value: VfxColorValue, + property: VfxProperty? = null, + hint: String? = null, + onChange: (VfxColorValue) -> Unit, +) { + val focus = LocalVfxFieldFocus.current + val driven = property?.let { LocalVfxDriven.current(it) } + var open by remember { mutableStateOf(false) } + var anchor by remember { mutableStateOf(UiRect.Zero) } + val current = when (value) { + is VfxColorValue.Solid -> "solid" + is VfxColorValue.Gradient -> "gradient" + is VfxColorValue.Channels -> "channels" + } + + if (driven != null) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint) + val authored = value.constants() + val shown = VfxRgba( + if (driven.drives(0)) driven.value(0) else authored[0], + if (driven.drives(1)) driven.value(1) else authored[1], + if (driven.drives(2)) driven.value(2) else authored[2], + if (driven.drives(3)) driven.value(3) else authored[3], + ) + Box( + tags = listOf("vfx-driven"), + modifier = Modifier.size(0.px, CurveCellHeight.px).grow(1f).input(hoverable = true) + .cursor(UiCursorShape.NOT_ALLOWED).tooltipOnHover(vfxText("driven_hint")).drawBehind(key = shown) { + drawChecker(size.width, size.height) + drawRect(UiPaint.Color(shown.toUi()), radius = 3f) + }, + ) + InspectorIcon(KeyframeIcon, vfxText("driven_show")) { focus(property) } + } + return + } + + Column(modifier = Modifier.size(100.percent).gap(2.px)) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint) + if (value is VfxColorValue.Channels) { + Text( + vfxText("color_channels"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).fontSize(9f).foreground(HintTint), + ) + } else { + ColorPalette( + value, + modifier = Modifier.size(0.px, CurveCellHeight.px).grow(1f).onPlaced { anchor = it }, + ) { + open = !open + if (property != null) focus(property) + } + } + ColorKindPicker(current, onChange) + } + + if (value is VfxColorValue.Channels) { + VfxValueRow(" R", value.r, property, channel = 0) { onChange(value.copy(r = it)) } + VfxValueRow(" G", value.g, property, channel = 1) { onChange(value.copy(g = it)) } + VfxValueRow(" B", value.b, property, channel = 2) { onChange(value.copy(b = it)) } + VfxValueRow(" A", value.a, property, channel = 3) { onChange(value.copy(a = it)) } + } + } + + if (!open || value is VfxColorValue.Channels) return + Popup( + anchorBounds = anchor, + id = "vfx-color-popup", + tags = listOf("vfx-popup"), + onDismiss = { open = false }, + ) { + when (value) { + is VfxColorValue.Solid -> ColorPicker( + value = value.color.toUi(), + onValueChange = { onChange(VfxColorValue.Solid(it.toRgba())) }, + ) + + is VfxColorValue.Gradient -> GradientEditor(value, onChange) + is VfxColorValue.Channels -> Unit + } + } +} + +@Composable +private fun GradientEditor(value: VfxColorValue.Gradient, onChange: (VfxColorValue) -> Unit) { + var editing by remember { mutableStateOf(0) } + + Column(modifier = Modifier.size(220.px, UiLength.Fit).gap(4.px)) { + ColorPalette(value, modifier = Modifier.size(100.percent, 14.px)) {} + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxSourcePicker(value.input) { onChange(value.copy(input = it)) } + InspectorButton(vfxText("stop_add")) { + val last = value.gradient.stops.lastOrNull() + val position = ((last?.position ?: 0f) + 0.25f).coerceAtMost(1f) + val stops = value.gradient.stops + VfxGradientStop(position, last?.color ?: VfxRgba.WHITE) + onChange(value.copy(gradient = VfxGradient(stops.sortedBy { it.position }))) + } + } + value.gradient.stops.forEachIndexed { index, stop -> + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + Box( + id = "vfx-gradient-stop-$index", + modifier = Modifier.size(PaletteWidth.px, CurveCellHeight.px) + .input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .drawBehind(key = stop.color to (index == editing)) { + drawRect(UiPaint.Color(stop.color.toUi()), radius = 3f) + if (index == editing) { + drawRect(UiRect(0f, size.height - 2f, size.width, 2f), UiPaint.Color(Accent)) + } + }.onClick { event -> + editing = index + event.consume() + }, + ) + Text(vfxText("stop_at"), modifier = Modifier.fontSize(8f).foreground(HintTint)) + VfxNumberCellInline(stop.position) { position -> + onChange( + value.copy( + gradient = value.gradient.withStop( + index, stop.copy(position = position.coerceIn(0f, 1f)) + ) + ) + ) + } + InspectorIcon(RemoveIcon, vfxText("key_remove")) { + onChange(value.copy(gradient = value.gradient.withoutStop(index))) + } + } + } + value.gradient.stops.getOrNull(editing)?.let { stop -> + ColorPicker( + value = stop.color.toUi(), + onValueChange = { picked -> + onChange( + value.copy( + gradient = value.gradient.withStop( + editing, stop.copy(color = picked.toRgba()) + ) + ) + ) + }, + ) + } + } +} + +@Composable +private fun ColorPalette(value: VfxColorValue, modifier: Modifier, onClick: () -> Unit) { + Box( + modifier = modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .drawBehind(key = value) { + val width = size.width + val height = size.height + if (width <= 0f || height <= 0f) return@drawBehind + + drawChecker(width, height) + when (value) { + is VfxColorValue.Solid -> drawRect(UiPaint.Color(value.color.toUi()), radius = 3f) + + is VfxColorValue.Gradient -> { + val steps = 32 + val columnWidth = width / steps + for (step in 0 until steps) { + val stop = value.gradient.colorAt(step.toFloat() / (steps - 1)) + drawRect( + UiRect(step * columnWidth, 0f, maxOf(columnWidth, 1f), height), + UiPaint.Color(stop.toUi()), + ) + } + } + + is VfxColorValue.Channels -> drawRect(UiPaint.Color(CurveBackground)) + } + }.onClick { event -> + onClick() + event.consume() + }, + ) +} + +/** A checkerboard under a color, so its alpha can be seen. */ +private fun UiCanvasDrawScope.drawChecker(width: Float, height: Float) { + val cell = 4f + var y = 0f + var row = 0 + while (y < height) { + var x = 0f + var column = row + while (x < width) { + val color = if (column % 2 == 0) CheckerLight else CheckerDark + drawRect(UiRect(x, y, minOf(cell, width - x), minOf(cell, height - y)), UiPaint.Color(color)) + x += cell + column++ + } + y += cell + row++ + } +} + +@Composable +private fun ColorKindPicker(current: String, onChange: (VfxColorValue) -> Unit) { + var open by remember { mutableStateOf(false) } + var anchor by remember { mutableStateOf(UiRect.Zero) } + + Box(modifier = Modifier.onPlaced { anchor = it }) { + InspectorIcon(colorKindIcon(current), vfxText("color_$current")) { open = true } + } + if (!open) return + + ContextMenu( + id = "vfx-color-kind", + anchorBounds = anchor, + items = listOf("solid", "gradient", "channels").map { kind -> + UiDropdownItem(vfxText("color_$kind"), icon = colorKindIcon(kind), checked = kind == current) { + onChange( + when (kind) { + "solid" -> VfxColorValue.Solid() + "gradient" -> VfxColorValue.Gradient() + else -> VfxColorValue.Channels() + } + ) + } + }, + onExpandedChange = { if (!it) open = false }, + ) +} + +private fun colorKindIcon(kind: String): String = when (kind) { + "solid" -> "hollowengine:textures/gui/icons/vfx/color_solid.svg" + "gradient" -> "hollowengine:textures/gui/icons/vfx/color_gradient.svg" + else -> "hollowengine:textures/gui/icons/vfx/color_channels.svg" +} + +private fun VfxGradient.withStop(index: Int, stop: VfxGradientStop): VfxGradient = + VfxGradient(stops.toMutableList().also { it[index] = stop }.sortedBy { it.position }) + +private fun VfxGradient.withoutStop(index: Int): VfxGradient = VfxGradient(stops.filterIndexed { at, _ -> at != index }) + +internal fun VfxRgba.toUi(): UiColor = UiColor(r, g, b, a) + +internal fun UiColor.toRgba(): VfxRgba = VfxRgba(red, green, blue, alpha) + +private const val PaletteWidth = 26f +private const val RemoveIcon = "hollowengine:textures/gui/icons/vfx/remove.svg" +private const val KeyframeIcon = "hollowengine:textures/gui/icons/vfx/key.svg" + +private val Accent = UiColor(0.84f, 0.5f, 0.11f, 1f) +private val CheckerLight = UiColor(0.32f, 0.33f, 0.36f, 1f) +private val CheckerDark = UiColor(0.2f, 0.21f, 0.23f, 1f) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxCurveDialog.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxCurveDialog.kt new file mode 100644 index 000000000..812ede82f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxCurveDialog.kt @@ -0,0 +1,117 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.FloatPropertyType +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.EmbeddedTimelineEditor +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineFeatures +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorButton +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurve +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurveInput +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue + +/** + * The curve of a value in graph editor. + */ +@Composable +internal fun VfxCurveDialog(value: VfxValue.OverTime, onChange: (VfxValue) -> Unit, onClose: () -> Unit) { + var current by remember { mutableStateOf(value) } + val controller = remember { curveController(value) } + + fun publish(next: VfxValue.OverTime) { + current = next + onChange(next) + } + + fun preset(curve: VfxCurve) { + controller.replaceKeys(curve) + publish(current.copy(curve = curve)) + } + + Popup( + anchorBounds = LocalUiViewport.current, + alignment = Centered, + id = "vfx-curve-dialog", + tags = listOf("vfx-popup", "vfx-curve-dialog"), + layer = 100, + modal = true, + onDismiss = onClose, + ) { + Column(modifier = Modifier.size(DialogWidth.px, DialogHeight.px).gap(6.px)) { + Row(modifier = Modifier.size(100.percent).gap(4.px).alignItems(vertical = UiAlign.CENTER)) { + Text(vfxText("curve_title"), tags = listOf("insp-title"), modifier = Modifier.grow(1f).fontSize(10f)) + InspectorButton(vfxText("curve_fade_out")) { preset(VfxCurve.ramp(1f, 0f)) } + InspectorButton(vfxText("curve_fade_in")) { preset(VfxCurve.ramp(0f, 1f)) } + InspectorButton(vfxText("curve_flat")) { preset(VfxCurve.flat(1f)) } + VfxFieldLabel(vfxText("curve_scale"), width = 58f) + Row(modifier = Modifier.size(56.px)) { + VfxNumberCellInline(current.scale) { publish(current.copy(scale = it)) } + } + VfxSourcePicker(current.input) { input -> + controller.workAreaEnd = domainOf(input, current.curve) + publish(current.copy(input = input)) + } + InspectorIcon(CloseIcon, vfxText("close")) { onClose() } + } + EmbeddedTimelineEditor( + controller = controller, + onChanged = { publish(current.copy(curve = controller.readCurve())) }, + features = CurveFeatures, + modifier = Modifier.size(100.percent, 0.px).grow(1f), + ) + } + } +} + +private fun curveController(value: VfxValue.OverTime): TimelineController { + val controller = TimelineController() + val name = vfxText("kind_curve") + val property = AnimProperty(CurvePropertyId, name, FloatPropertyType(name), value.curve.valueAt(0f, 1f)) + controller.addProperty(listOf(name), property) + controller.replaceKeys(value.curve) + controller.workAreaEnd = domainOf(value.input, value.curve) + controller.headerWidth = HeaderWidth + controller.pixelsPerSecond = (GraphWidth / controller.workAreaEnd).coerceIn(10f, 500f) + controller.enterCurveView() + controller.frameCurves() + return controller +} + +private fun domainOf(input: VfxCurveInput, curve: VfxCurve): Float = + if (input == VfxCurveInput.LIFETIME || input == VfxCurveInput.RANDOM || input == VfxCurveInput.PARENT_RANDOM) 1f else (curve.keys.maxOfOrNull { it.time } ?: 1f).coerceAtLeast(1f) + +private fun TimelineController.curve() = allProperties().single().curves.single() + +private fun TimelineController.replaceKeys(curve: VfxCurve) { + val target = curve() + target.keyframes.clear() + curve.keys.forEach { key -> target.keyframes.add(key.toEditor()) } + target.sort() + clearSelection() + frameCurves() +} + +private fun TimelineController.readCurve(): VfxCurve { + val target = curve() + return VfxCurve(target.keyframes.map { key -> key.toStored(target) }) +} + +private val Centered = UiPopupAlignment( + anchorHorizontal = UiAlign.CENTER, + anchorVertical = UiAlign.CENTER, + popupHorizontal = UiAlign.CENTER, + popupVertical = UiAlign.CENTER, +) + +private val CurveFeatures = TimelineFeatures(capture = false, storage = false, cameraPreview = false, playback = false) + +private const val CurvePropertyId = "vfx-curve" +private const val DialogWidth = 780f +private const val DialogHeight = 440f +private const val HeaderWidth = 150f + +private const val GraphWidth = DialogWidth - HeaderWidth - 220f - 80f + +private const val CloseIcon = "hollowengine:textures/gui/icons/timeline/close.svg" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxDriven.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxDriven.kt new file mode 100644 index 000000000..22c776d16 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxDriven.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeRuntime +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty + +/** + * What the timeline holds for the properties of [nodeId], as the preview plays it. + */ +internal fun vfxDrivenLookup( + document: HollowIdeVfxDocument, + preview: VfxPreviewState, + nodeId: String, +): (VfxProperty) -> VfxDrivenValue? = lookup@{ property -> + val track = document.effect.timeline.track(nodeId, property) ?: return@lookup null + val channels = track.curves.filter { it.visible && it.keys.isNotEmpty() }.map { it.channel }.toSet() + if (channels.isEmpty()) return@lookup null + + preview.time + preview.revision + VfxDrivenValue(currentValues(preview.instance?.node(nodeId), property), channels) +} + +/** The values the running node has for [property] this frame. */ +private fun currentValues(node: VfxNodeRuntime?, property: VfxProperty): FloatArray { + if (node == null) return FloatArray(4) + return when (property) { + VfxProperty.POSITION -> node.transform.position.asArray() + VfxProperty.ROTATION -> node.transform.rotation.asArray() + VfxProperty.SCALE -> node.transform.scale.asArray() + VfxProperty.ENABLED -> floatArrayOf(if (node.enabled) 1f else 0f) + else -> node.drive(property)?.values?.copyOf() ?: FloatArray(4) + } +} + +private fun Vec3f.asArray() = floatArrayOf(x, y, z) 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 new file mode 100644 index 000000000..63230a9fe --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEditorPanel.kt @@ -0,0 +1,356 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.* +import com.mojang.blaze3d.systems.RenderSystem +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.editor.GizmoEditMode +import ru.hollowhorizon.hollowengine.client.editor.GizmoHandleId +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOpenFile +import ru.hollowhorizon.hollowengine.client.ui.ide.PublishScene +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.ide.files.animator.AnimatorColors +import ru.hollowhorizon.hollowengine.client.ui.ide.files.animator.AnimatorIconButton +import ru.hollowhorizon.hollowengine.client.ui.ide.files.animator.AnimatorStylesheet +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.PublishTimeline +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineTarget +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.hasInspectableSelection +import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector +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.utils.lang +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import kotlin.time.Duration.Companion.milliseconds + +private const val AutoSaveDelayMillis = 900L + +private const val PlayIcon = "hollowengine:textures/gui/icons/timeline/play.svg" +private const val PauseIcon = "hollowengine:textures/gui/icons/timeline/pause.svg" +private const val RestartIcon = "hollowengine:textures/gui/icons/vfx/restart.svg" +private const val FloorIcon = "hollowengine:textures/gui/icons/vfx/floor.svg" +private const val ShapeIcon = "hollowengine:textures/gui/icons/vfx/handles.svg" + +/** + * The editor for a `.vfx` file. + * + * The hierarchy goes to the scene window, curves to the timeline window and the fields to the + * inspector. + */ +@Composable +internal fun VfxEditorPanel(file: HollowIdeOpenFile) { + val document = file.document as HollowIdeVfxDocument + val state = remember(document) { document.editorState { VfxEditorState(document) } } + val session = state.session + + LaunchedEffect(document.revision) { + file.updateDirty(document.isModified) + session.sync() + if (!document.isModified) return@LaunchedEffect + delay(AutoSaveDelayMillis.milliseconds) + if (document.isModified) file.save() + } + + DisposableEffect(document) { + onDispose { + RenderSystem.recordRenderCall { + state.preview.close() + state.materialPreview.close() + } + } + } + + LaunchedEffect(document) { + while (isActive) { + withFrameNanos { frameNanos -> + state.preview.advanceCamera(frameNanos) + val rebuilt = state.preview.sync(document.effect, file.path) + val sought = state.preview.applyPendingSeek() + val delta = state.preview.deltaSinceLastFrame(frameNanos) + if (session.timeline.isPlaying && delta > 0f) state.preview.advanceBy(delta) + if (rebuilt || session.timeline.isPlaying && !sought) { + session.timeline.followPlayhead(state.preview.time) + } + } + } + } + + val selected = state.selected?.takeIf { document.effect.node(it) != null } + + val timelineHasSelection = session.timeline.hasInspectableSelection() + PublishInspector(source = "vfx-${file.path}", key = selected to timelineHasSelection) { + when { + timelineHasSelection -> null + selected != null -> vfxNodeInspector(document, state, selected) + else -> vfxEffectInspector(document, state) + } + } + + PublishScene( + source = "vfx-${file.path}", + key = listOf(document.revision, state.selected, state.expanded.toList(), state.rootExpanded), + ) { + vfxSceneTarget(document, state, file.path.substringAfterLast('/').substringBeforeLast('.')) + } + + PublishTimeline(source = "vfx-${file.path}", key = file.path) { + TimelineTarget( + id = "vfx-${file.path}", + controller = session.timeline, + refresh = { + session.commit() + session.applyListing() + }, + ) + } + + Column( + modifier = Modifier.size(100.percent, 100.percent).style(AnimatorStylesheet).background(AnimatorColors.Canvas) + .focusScope(), + ) { + document.error?.let { message -> + Text( + vfxText("broken_file") + " " + message, + modifier = Modifier.size(100.percent).padding(6.px).fontSize(10f).foreground(AnimatorColors.Muted), + ) + } + + Box(mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 0.px).grow(1f)) { + Viewport(document, state, selected) + Toolbar(state) + ParticleCounter(state.preview) + } + } +} + +@Composable +private fun Toolbar(state: VfxEditorState) { + val timeline = state.session.timeline + val preview = state.preview + Row(tags = listOf("viewport-toolbar"), modifier = Modifier.position(8.px, 8.px)) { + AnimatorIconButton( + icon = if (timeline.isPlaying) PauseIcon else PlayIcon, + tooltip = vfxText(if (timeline.isPlaying) "pause" else "play"), + size = 12f, + active = timeline.isPlaying, + ) { timeline.isPlaying = !timeline.isPlaying } + AnimatorIconButton(RestartIcon, vfxText("restart"), size = 12f) { + preview.restart() + timeline.followPlayhead(0f) + } + AnimatorIconButton(FloorIcon, vfxText("toggle_floor"), size = 12f, active = preview.showFloor) { + preview.showFloor = !preview.showFloor + } + AnimatorIconButton(ShapeIcon, vfxText("toggle_shape"), size = 12f, active = preview.showShape) { + preview.showShape = !preview.showShape + } + GizmoModes.forEach { (mode, icon, tooltip) -> + AnimatorIconButton(icon, tooltip.lang, size = 12f, active = mode in state.gizmoModes) { + state.gizmoModes = if (mode in state.gizmoModes) state.gizmoModes - mode else state.gizmoModes + mode + } + } + } +} + +/** What editor is looking at, as opposed to what it is editing. */ +internal class VfxEditorState(document: HollowIdeVfxDocument) { + val preview = VfxPreviewState() + val session = VfxTimelineSession(document, preview).also { it.timeline.isPlaying = true } + + var selected by mutableStateOf(null) + private set + val expanded = mutableStateListOf() + var rootExpanded by mutableStateOf(true) + + /** Which inspector sections are open, kept here so they survive switching between nodes. */ + private val sections = mutableStateMapOf() + + /** The material of the selected surface on its own, in the material section of the inspector. */ + val materialPreview = materialPreviewState() + + /** The shape handle being dragged, with node as it was when drag started. */ + var drag: VfxGizmoHandle? = null + + /** The transform gizmo of the world, drawn over the preview, and what it is set to move. */ + val transformGizmo = VfxTransformGizmo() + var gizmoModes by mutableStateOf(setOf(GizmoEditMode.TRANSLATE)) + var transformDrag: VfxTransformDrag? = null + var hoveredHandle by mutableStateOf(null) + + /** What dragged handle is set to right now, drawn beside it. */ + var readout by mutableStateOf(null) + + fun isSectionOpen(key: String): Boolean = sections[key] ?: false + + fun toggleSection(key: String) { + sections[key] = !isSectionOpen(key) + } + + fun select(id: String?) { + selected = id + id?.let(session::touch) + session.timeline.clearSelection() + } + + fun focusProperty(nodeId: String, property: VfxProperty) = session.focus(nodeId, property) +} + +/** + * The preview itself, and the gizmo over it. + */ +@Composable +private fun Viewport(document: HollowIdeVfxDocument, state: VfxEditorState, selected: String?) { + val preview = state.preview + val node = selected?.let { document.effect.node(it) } + val runtime = selected?.let { preview.instance?.node(it) } + val driven = selected?.let { vfxDrivenLookup(document, preview, it) } ?: { null } + + val gizmoKey = listOf( + preview.yaw, preview.pitch, preview.distance, preview.targetX, preview.targetY, preview.targetZ, + preview.viewportWidth, preview.viewportHeight, preview.time, preview.showShape, preview.revision, node, + state.gizmoModes, + ) + val gizmo = remember(gizmoKey) { + if (node == null) VfxGizmo.EMPTY else VfxGizmos.build(preview, node, runtime, preview.showShape, driven) + } + val transform = state.transformGizmo + val handles = remember(gizmoKey) { + if (node == null || runtime == null || preview.viewportWidth <= 1f) return@remember emptyList() + transform.capture(preview) + transform.handles(runtime.frame, state.gizmoModes, driven) + } + + Box( + id = "vfx-viewport", + mode = UiBoxMode.STACK, + modifier = Modifier.size(100.percent, 100.percent).clip().onPlaced { rect -> + preview.viewportWidth = rect.width + preview.viewportHeight = rect.height + }.input(hoverable = true, draggable = true).cursor(UiCursorShape.HAND).focus() + .onKeyInput { input -> if (handleHistoryKeys(document, input)) input.consume() }.onPress { event -> + val left = event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT + val handle = if (left) gizmo.handleAt(event.localX, event.localY) else null + val moving = if (left && handle == null && node != null && runtime != null) { + transform.pick(handles, event.localX, event.localY)?.let { picked -> + transform.begin(picked, runtime.frame, runtime.parent?.frame, node.transform, event.localX, event.localY) + } + } else null + state.drag = handle + state.transformDrag = moving + state.readout = null + if (handle != null || moving != null) document.beginGesture() else preview.beginCameraDrag() + }.onDrag { event -> + val handle = state.drag + val moving = state.transformDrag + val current = selected?.let { document.effect.node(it) } + when { + moving != null && current != null -> { + transform.drag(moving, event.localX, event.localY, event.modifiers)?.let { placed -> + document.edit { it.withNode(current.withCommon(transform = placed)) } + } + state.readout = transform.labelAt(moving)?.let { (x, y) -> VfxGizmoReadout(x, y, moving.label) } + } + + handle != null && current != null -> { + val fine = event.modifiers and GLFW.GLFW_MOD_SHIFT != 0 + val snap = event.modifiers and GLFW.GLFW_MOD_CONTROL != 0 + val distance = VfxGizmos.dragDistance(handle, event.dragTotalX, event.dragTotalY, fine, snap) + document.edit { it.withNode(handle.apply(current, distance)) } + state.readout = VfxGizmoReadout(handle.x, handle.y, handle.readout(distance)) + } + + event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT -> preview.pan(event.dragTotalX, event.dragTotalY) + else -> preview.orbit(event.dragTotalX, event.dragTotalY) + } + event.consume() + }.onRelease { + if (state.drag != null || state.transformDrag != null) document.endGesture() + state.drag = null + state.transformDrag = null + state.readout = null + }.onHover { event -> + val over = transform.pick(handles, event.localX, event.localY)?.id + if (over != state.hoveredHandle) state.hoveredHandle = over + }.onScroll { event -> + preview.zoom(event.scrollY) + event.consume() + }.drawBehind(key = preview) { + drawGl { preview.render(rect, poseStack) } + }, + ) { + Box( + modifier = Modifier.size(100.percent, 100.percent).inputTransparent() + .drawBehind(key = listOf(gizmo, handles, state.drag?.id, state.hoveredHandle)) { + drawGizmo(gizmo, state.drag?.id) + transform.draw(this, handles, state.hoveredHandle, state.transformDrag) + }, + ) + state.readout?.let { readout -> + Text( + formatNumber(readout.value), + tags = listOf("vfx-gizmo-readout"), + modifier = Modifier.position((readout.x + 10f).px, (readout.y - 20f).px).inputTransparent(), + ) + } + } +} + +private fun handleHistoryKeys(document: HollowIdeVfxDocument, input: UiKeyInput): Boolean { + if (input.repeat || !input.control) return false + return when (input.key) { + GLFW.GLFW_KEY_Z if input.shift -> document.redo() + GLFW.GLFW_KEY_Z -> document.undo() + GLFW.GLFW_KEY_Y -> document.redo() + else -> false + } +} + +/** The number beside the handle being dragged. */ +internal class VfxGizmoReadout(val x: Float, val y: Float, val value: Float) + +private fun UiCanvasDrawScope.drawGizmo(gizmo: VfxGizmo, active: String?) { + gizmo.lines.groupBy { it.color }.forEach { (color, lines) -> + val path = GenericShape { + lines.forEach { line -> + moveTo(line.x0, line.y0) + lineTo(line.x1, line.y1) + } + } + drawShape(path, bounds, UiPaint.Color(color), UiDrawStyle.Stroke(width = 1.4f)) + } + gizmo.handles.forEach { handle -> + val half = if (handle.id == active) 5f else 4f + drawRect( + UiRect(handle.x - half - 1f, handle.y - half - 1f, half * 2f + 2f, half * 2f + 2f), + UiPaint.Color(HandleOutline), + radius = 2f, + ) + drawRect( + UiRect(handle.x - half, handle.y - half, half * 2f, half * 2f), UiPaint.Color(handle.color), radius = 1.5f + ) + } +} + +@Composable +private fun ParticleCounter(preview: VfxPreviewState) { + if (preview.viewportHeight <= 0f) return + Text( + vfxText("particles") + ": " + preview.liveParticles, + modifier = Modifier.position(8.px, (preview.viewportHeight - 18f).px).padding(4.px, 1.px) + .background(CounterBackground).borderRadius(3f).fontSize(9f).foreground(AnimatorColors.Muted) + .inputTransparent(), + ) +} + +/** The modes of the transform gizmo, with the icons and names the IDE toolbar gives them for the world. */ +private val GizmoModes = listOf( + Triple(GizmoEditMode.TRANSLATE, "hollowengine:textures/gui/icons/gizmo_translate.svg", "hollowengine.gui.ide.gizmo.translate"), + Triple(GizmoEditMode.ROTATE, "hollowengine:textures/gui/icons/gizmo_rotate.svg", "hollowengine.gui.ide.gizmo.rotate"), + Triple(GizmoEditMode.SCALE, "hollowengine:textures/gui/icons/gizmo_scale.svg", "hollowengine.gui.ide.gizmo.scale"), +) + +private val HandleOutline = UiColor(0.05f, 0.05f, 0.06f, 0.9f) +private val CounterBackground = UiColor(0.06f, 0.07f, 0.08f, 0.7f) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEmitterInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEmitterInspector.kt new file mode 100644 index 000000000..6d944d5eb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxEmitterInspector.kt @@ -0,0 +1,190 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.inspector.Pills +import ru.hollowhorizon.hollowengine.client.ui.inspector.ToggleRow +import ru.hollowhorizon.hollowengine.common.vfx.VfxDirectionMode +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmitterSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxParticleEvent +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxShape +import ru.hollowhorizon.hollowengine.common.vfx.VfxShapeKind +import ru.hollowhorizon.hollowengine.common.vfx.VfxSimulationSpace +import ru.hollowhorizon.hollowengine.common.vfx.VfxSubEmission + +/** + * How many particles, where they are born, what they start with and how they move. How they look is + * up to the renderers under the emitter, each for itself. + * + * An emitter [underEmitter] may spawn from the particles of that one instead; its own emission then + * does nothing but cap the count. + */ +@Composable +internal fun EmitterFields( + document: HollowIdeVfxDocument, + state: VfxEditorState, + emitter: VfxEmitterSpec, + underEmitter: Boolean, +) { + val sub = emitter.subEmission?.takeIf { underEmitter } + if (underEmitter) SubEmissionFields(document, state, emitter) + + val emission = emitter.emission + Folding(state, "emission", vfxText("section_emission"), VfxIcons.EMISSION) { + if (sub != null) { + VfxIntRow(vfxText("max_particles"), emission.maxParticles, vfxText("max_particles_hint"), min = 1, max = 20_000) { + document.replace(emitter.copy(emission = emission.copy(maxParticles = it))) + } + return@Folding + } + VfxValueRow(vfxText("rate"), emission.rate, VfxProperty.RATE, vfxText("rate_hint")) { + document.replace(emitter.copy(emission = emission.copy(rate = it))) + } + VfxIntRow(vfxText("burst"), emission.burst, vfxText("burst_hint"), min = 0, max = 4096) { + document.replace(emitter.copy(emission = emission.copy(burst = it))) + } + VfxNumberRow(vfxText("duration"), emission.duration, vfxText("duration_hint"), min = 0f) { + document.replace(emitter.copy(emission = emission.copy(duration = it))) + } + ToggleRow(vfxText("loop"), emission.loop) { + document.replace(emitter.copy(emission = emission.copy(loop = it))) + } + if (emission.loop) { + VfxNumberRow(vfxText("loop_delay"), emission.loopDelay, vfxText("loop_delay_hint"), min = 0f) { + document.replace(emitter.copy(emission = emission.copy(loopDelay = it))) + } + } + VfxIntRow(vfxText("max_particles"), emission.maxParticles, vfxText("max_particles_hint"), min = 1, max = 20_000) { + document.replace(emitter.copy(emission = emission.copy(maxParticles = it))) + } + } + + ShapeFields(document, state, emitter) + + val spawn = emitter.spawn + Folding(state, "spawn", vfxText("section_spawn"), VfxIcons.SPAWN) { + VfxValueRow(vfxText("lifetime"), spawn.lifetime, VfxProperty.LIFETIME, vfxText("lifetime_hint")) { + document.replace(emitter.copy(spawn = spawn.copy(lifetime = it))) + } + VfxValueRow(vfxText("speed"), spawn.speed, VfxProperty.SPEED, vfxText("speed_hint")) { + document.replace(emitter.copy(spawn = spawn.copy(speed = it))) + } + VfxVec3Row(vfxText("spawn_offset"), spawn.offset, VfxProperty.OFFSET, vfxText("spawn_offset_hint")) { + document.replace(emitter.copy(spawn = spawn.copy(offset = it))) + } + VfxValueRow( + vfxText("inherit_velocity"), + spawn.inheritVelocity, + VfxProperty.INHERIT_VELOCITY, + vfxText("inherit_velocity_hint"), + ) { + document.replace(emitter.copy(spawn = spawn.copy(inheritVelocity = it))) + } + } + + val motion = emitter.motion + Folding(state, "motion", vfxText("section_motion"), VfxIcons.MOTION) { + VfxValueRow(vfxText("gravity"), motion.gravity, VfxProperty.GRAVITY, vfxText("gravity_hint")) { + document.replace(emitter.copy(motion = motion.copy(gravity = it))) + } + VfxValueRow(vfxText("drag"), motion.drag, VfxProperty.DRAG, vfxText("drag_hint")) { + document.replace(emitter.copy(motion = motion.copy(drag = it))) + } + } + + VfxModuleSections(document, state, emitter) + + Folding(state, "space", vfxText("section_space"), VfxIcons.SPACE) { + Pills(VfxSimulationSpace.entries, emitter.space, { vfxText("space_${it.name.lowercase()}") }) { + document.replace(emitter.copy(space = it)) + } + if (emitter.space == VfxSimulationSpace.LOCAL) { + ToggleRow(vfxText("inherit_rotation"), emitter.inheritRotation) { + document.replace(emitter.copy(inheritRotation = it)) + } + ToggleRow(vfxText("inherit_scale"), emitter.inheritScale) { + document.replace(emitter.copy(inheritScale = it)) + } + } + VfxNumberRow(vfxText("max_step"), emitter.maxStep, vfxText("max_step_hint"), min = 1f / 240f, max = 0.25f) { + document.replace(emitter.copy(maxStep = it)) + } + } +} + +/** Whether and when the emitter spawns from the particles of the emitter it sits under. */ +@Composable +private fun SubEmissionFields(document: HollowIdeVfxDocument, state: VfxEditorState, emitter: VfxEmitterSpec) { + val sub = emitter.subEmission + fun update(next: VfxSubEmission) = document.replace(emitter.copy(subEmission = next)) + + Folding(state, "sub_emission", vfxText("section_sub_emission"), VfxIcons.SUB_EMITTER) { + ToggleRow(vfxText("sub_enabled"), sub != null, hint = vfxText("sub_enabled_hint")) { on -> + document.replace(emitter.copy(subEmission = if (on) VfxSubEmission() else null)) + } + if (sub == null) return@Folding + + Pills(VfxParticleEvent.entries, sub.event, { vfxText("sub_event_${it.name.lowercase()}") }) { + update(sub.copy(event = it)) + } + val countHint = if (sub.event == VfxParticleEvent.ALIVE) "sub_count_alive_hint" else "sub_count_hint" + VfxValueRow(vfxText("sub_count"), sub.count, hint = vfxText(countHint)) { update(sub.copy(count = it)) } + VfxValueRow(vfxText("inherit_velocity"), sub.inheritVelocity, hint = vfxText("sub_inherit_velocity_hint")) { + update(sub.copy(inheritVelocity = it)) + } + } +} + +/** The shape shows only what chosen kind reads. */ +@Composable +private fun ShapeFields(document: HollowIdeVfxDocument, state: VfxEditorState, emitter: VfxEmitterSpec) { + val shape = emitter.shape + fun update(next: VfxShape) = document.replace(emitter.copy(shape = next)) + + Folding(state, "shape", vfxText("section_shape"), VfxIcons.SHAPE) { + Pills(VfxShapeKind.entries, shape.kind, { vfxText("shape_${it.name.lowercase()}") }) { + update(shape.copy(kind = it)) + } + + val round = shape.kind == VfxShapeKind.SPHERE || shape.kind == VfxShapeKind.CONE || shape.kind == VfxShapeKind.DISC + if (round) { + VfxValueRow(vfxText("radius"), shape.radius, VfxProperty.SHAPE_RADIUS) { update(shape.copy(radius = it)) } + VfxValueRow(vfxText("thickness"), shape.thickness, VfxProperty.SHAPE_THICKNESS, vfxText("thickness_hint")) { + update(shape.copy(thickness = it)) + } + } + if (shape.kind == VfxShapeKind.CONE) { + VfxValueRow(vfxText("angle"), shape.angle, VfxProperty.SHAPE_ANGLE, vfxText("angle_hint")) { + update(shape.copy(angle = it)) + } + } + if (shape.kind == VfxShapeKind.BOX) { + VfxVec3Row(vfxText("extents"), shape.extents, VfxProperty.SHAPE_EXTENTS, vfxText("extents_hint")) { + update(shape.copy(extents = it)) + } + } + if (shape.kind == VfxShapeKind.LINE) { + VfxVec3Row(vfxText("line_end"), shape.extents, VfxProperty.SHAPE_EXTENTS, vfxText("line_end_hint")) { + update(shape.copy(extents = it)) + } + } + if (shape.kind == VfxShapeKind.MODEL) { + VfxAssetRow(vfxText("model"), shape.model, VfxModelExtensions, vfxText("shape_model_hint")) { + update(shape.copy(model = it)) + } + } + + Text(vfxText("direction_mode"), tags = listOf("insp-inline-label")) + Pills(VfxDirectionMode.entries, shape.directionMode, { vfxText("aim_${it.name.lowercase()}") }) { + update(shape.copy(directionMode = it)) + } + val flat = shape.kind == VfxShapeKind.POINT || shape.kind == VfxShapeKind.BOX || shape.kind == VfxShapeKind.LINE + val readsDirection = shape.directionMode == VfxDirectionMode.FIXED || + shape.directionMode == VfxDirectionMode.SHAPE && flat + if (readsDirection) { + VfxFloat3Row(vfxText("direction"), shape.direction) { update(shape.copy(direction = it)) } + } + } +} 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 new file mode 100644 index 000000000..eed8e4f89 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxFields.kt @@ -0,0 +1,487 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorButton +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorIconButton +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint +import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow +import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu +import ru.hollowhorizon.hollowengine.client.ui.widgets.ExpressionEditing +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextInputFilter +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.client.vfx.VfxExpressionLanguage +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurve +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurveInput +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value +import kotlin.math.abs + +/** + * What the inspector calls when a field of an animatable property takes the focus, with the id of + * that property. + */ +val LocalVfxFieldFocus = staticCompositionLocalOf<(VfxProperty) -> Unit> { {} } + +/** + * What the timeline currently writes into a property, or null when it has no keys for it. + */ +val LocalVfxDriven = staticCompositionLocalOf<(VfxProperty) -> VfxDrivenValue?> { { null } } + +/** The value a timeline track holds right now, and which of its channels track has keys on. */ +class VfxDrivenValue(val values: FloatArray, private val channels: Set) { + fun drives(channel: Int): Boolean = channel in channels + + fun value(channel: Int): Float = values.getOrElse(channel) { 0f } +} + +/** + * The shapes a number can take. + * + * Switching between them keeps the row where it is: a constant is one field, a range two, an + * 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"); +} + +private fun VfxValue.kind(): VfxValueKind = when (this) { + is VfxValue.Const -> VfxValueKind.CONST + is VfxValue.Range -> VfxValueKind.RANGE + is VfxValue.OverTime -> VfxValueKind.CURVE + is VfxValue.Expr -> VfxValueKind.EXPR +} + +/** A sample of the value, for carrying something over when the shape changes. */ +private fun VfxValue.sample(): Float = when (this) { + is VfxValue.Const -> value + is VfxValue.Range -> from + is VfxValue.OverTime -> curve.valueAt(0f) + is VfxValue.Expr -> source.trim().toFloatOrNull() ?: 1f +} + +private fun VfxValue.asKind(kind: VfxValueKind): VfxValue { + if (kind() == kind) return this + val sample = sample() + return when (kind) { + VfxValueKind.CONST -> VfxValue.Const(sample) + VfxValueKind.RANGE -> VfxValue.Range(sample, sample) + VfxValueKind.CURVE -> VfxValue.OverTime(VfxCurve.ramp(sample, 0f)) + VfxValueKind.EXPR -> VfxValue.Expr(formatNumber(sample)) + } +} + +internal fun formatNumber(value: Float): String = + if (value == value.toInt().toFloat() && abs(value) < 1.0e7f) value.toInt().toString() + else "%.4f".format(value).replace(',', '.').trimEnd('0').trimEnd('.') + +/** + * The label of a row. What the field means is its tooltip rather than a line of text under it, so a + * section stays one row per value. + */ +@Composable +fun VfxFieldLabel(label: String, hint: String? = null, width: Float = FieldLabelWidth) { + if (label.isEmpty()) return + val hinted = !hint.isNullOrBlank() + Text( + label, + tags = if (hinted) listOf("insp-inline-label", "hinted") else listOf("insp-inline-label"), + modifier = Modifier.size(width.px, UiLength.Fit).textWrap(false).textOverflow(UiTextOverflow.DOTS) + .then(if (hinted) Modifier.tooltipOnHover(hint.orEmpty()) else Modifier), + ) +} + +/** + * One authored number on one line. + */ +@Composable +fun VfxValueRow( + label: String, + value: VfxValue, + property: VfxProperty? = null, + hint: String? = null, + channel: Int = 0, + onChange: (VfxValue) -> Unit, +) { + val driven = property?.let { LocalVfxDriven.current(it) }?.takeIf { it.drives(channel) } + + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint) + if (driven != null) { + DrivenCell(driven.value(channel)) + DrivenMark(property) + } else { + ValueCells(value, property, onChange) + KindPicker(value, onChange) + } + } +} + +/** + * Three values that make one vector. + */ +@Composable +fun VfxVec3Row( + label: String, + value: VfxVec3Value, + property: VfxProperty? = null, + hint: String? = null, + uniform: Boolean = false, + onChange: (VfxVec3Value) -> Unit, +) { + if (uniform) { + VfxValueRow(label, value.x, property, hint) { onChange(VfxVec3Value(it, it, it)) } + return + } + + val compact = value.x is VfxValue.Const && value.y is VfxValue.Const && value.z is VfxValue.Const + if (!compact) { + Column(modifier = Modifier.size(100.percent).gap(2.px)) { + Row(modifier = Modifier.size(100.percent).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel( + label, + hint + ) + } + VfxValueRow(" X", value.x, property, channel = 0) { onChange(value.copy(x = it)) } + VfxValueRow(" Y", value.y, property, channel = 1) { onChange(value.copy(y = it)) } + VfxValueRow(" Z", value.z, property, channel = 2) { onChange(value.copy(z = it)) } + } + return + } + + val driven = property?.let { LocalVfxDriven.current(it) } + 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 + ) { onChange(value.copy(x = VfxValue.of(it))) } + AxisNumber( + "Y", + value.y.value, + property, + driven, + 1 + ) { onChange(value.copy(y = VfxValue.of(it))) } + AxisNumber( + "Z", + value.z.value, + property, + driven, + 2 + ) { onChange(value.copy(z = VfxValue.of(it))) } + if (driven != null) { + DrivenMark(property) + } else { + KindPicker(value.x) { picked -> + onChange(VfxVec3Value(picked, value.y.asKind(picked.kind()), value.z.asKind(picked.kind()))) + } + } + } +} + +/** Three plain numbers on one line: a position, a direction, an axis. */ +@Composable +fun VfxFloat3Row( + label: String, + value: Vec3f, + property: VfxProperty? = null, + hint: String? = null, + onChange: (Vec3f) -> Unit, +) { + val driven = property?.let { LocalVfxDriven.current(it) } + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint, VectorLabelWidth) + AxisNumber("X", value.x, property, driven, 0) { onChange(Vec3f(it, value.y, value.z)) } + AxisNumber("Y", value.y, property, driven, 1) { onChange(Vec3f(value.x, it, value.z)) } + AxisNumber("Z", value.z, property, driven, 2) { onChange(Vec3f(value.x, value.y, it)) } + if (driven != null) DrivenMark(property) + } +} + +/** One plain number, for the settings that are not values: a duration, a limit, a step. */ +@Composable +fun VfxNumberRow( + label: String, + value: Float, + hint: String? = null, + min: Float = -Float.MAX_VALUE, + max: Float = Float.MAX_VALUE, + onChange: (Float) -> Unit, +) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint) + NumberCell(value, null, whole = false) { onChange(it.coerceIn(min, max)) } + } +} + +@Composable +fun VfxIntRow( + label: String, + value: Int, + hint: String? = null, + min: Int = Int.MIN_VALUE, + max: Int = Int.MAX_VALUE, + onChange: (Int) -> Unit, +) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint) + NumberCell(value.toFloat(), null, whole = true) { onChange(it.toInt().coerceIn(min, max)) } + } +} + +/** An axis name in its color, then the number, or what the timeline holds for it. */ +@Composable +private fun AxisNumber( + axis: String, + value: Float, + property: VfxProperty?, + driven: VfxDrivenValue?, + channel: Int, + onChange: (Float) -> Unit, +) { + Row(modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).gap(2.px).alignItems(vertical = UiAlign.CENTER)) { + Text(axis, modifier = Modifier.fontSize(8f).foreground(axisColor(axis))) + if (driven != null && driven.drives(channel)) { + DrivenCell(driven.value(channel)) + } else { + NumberCell(value, property, whole = false, onChange = onChange) + } + } +} + +private fun axisColor(axis: String): UiColor = when (axis) { + "X", "U" -> UiColor(0.91f, 0.42f, 0.42f, 1f) + "Y", "V" -> UiColor(0.52f, 0.82f, 0.45f, 1f) + else -> UiColor(0.45f, 0.62f, 0.95f, 1f) +} + +@Composable +private fun DrivenCell(value: Float) { + Box( + tags = listOf("insp-input", "insp-inline-input", "vfx-driven"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).input(hoverable = true).cursor(UiCursorShape.NOT_ALLOWED) + .tooltipOnHover(vfxText("driven_hint")), + ) { + Text(formatNumber(value), modifier = Modifier.fontSize(9f).textWrap(false)) + } +} + +@Composable +private fun DrivenMark(property: VfxProperty?) { + val focus = LocalVfxFieldFocus.current + InspectorIcon(KeyframeIcon, vfxText("driven_show")) { if (property != null) focus(property) } +} + +@Composable +private fun NumberCell(value: Float, property: VfxProperty?, whole: Boolean, onChange: (Float) -> Unit) { + val focus = LocalVfxFieldFocus.current + var draft by remember { mutableStateOf(formatNumber(value)) } + var lastExternal by remember { mutableStateOf(value) } + if (value != lastExternal) { + lastExternal = value + if (draft.toFloatOrNull() != value) draft = formatNumber(value) + } + + TextField( + value = draft, + fontSize = 9f, + filter = if (whole) UiTextInputFilter.INTEGER else UiTextInputFilter.DECIMAL, + tags = listOf("insp-input", "insp-inline-input", "numeric"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).onFocus { if (property != null) focus(property) }, + onChange = { text -> + draft = text + text.toFloatOrNull()?.let { number -> + lastExternal = number + onChange(number) + } + }, + ) +} + +@Composable +internal fun VfxNumberCellInline(value: Float, onChange: (Float) -> Unit) = + NumberCell(value, null, whole = false, onChange = onChange) + +@Composable +private fun TextCell(text: String, property: VfxProperty?, onChange: (String) -> Unit) { + val focus = LocalVfxFieldFocus.current + var draft by remember { mutableStateOf(text) } + var lastExternal by remember { mutableStateOf(text) } + if (text != lastExternal) { + lastExternal = text + draft = text + } + + val diagnostics = remember(draft) { VfxExpressionEditing.diagnostics(draft) } + TextField( + value = draft, + fontSize = 9f, + placeholder = vfxText("expression_placeholder"), + completionContributor = VfxExpressionEditing.completions, + syntaxHighlighter = 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), + onChange = { typed -> + draft = typed + lastExternal = typed + onChange(typed) + }, + ) +} + +@Composable +private fun ValueCells(value: VfxValue, property: VfxProperty?, onChange: (VfxValue) -> Unit) { + when (value) { + is VfxValue.Const -> NumberCell(value.value, property, whole = false) { onChange(VfxValue.Const(it)) } + + is VfxValue.Range -> { + NumberCell(value.from, property, whole = false) { onChange(value.copy(from = it)) } + Text("—", modifier = Modifier.fontSize(8f).foreground(HintTint)) + NumberCell(value.to, property, whole = false) { onChange(value.copy(to = it)) } + } + + is VfxValue.Expr -> TextCell(value.source, property) { onChange(VfxValue.Expr(it)) } + is VfxValue.OverTime -> CurveCell(value, property, onChange) + } +} + +@Composable +private fun CurveCell(value: VfxValue.OverTime, property: VfxProperty?, onChange: (VfxValue) -> Unit) { + val focus = LocalVfxFieldFocus.current + var editing by remember { mutableStateOf(false) } + + VfxCurvePreview( + curve = value.curve, + modifier = Modifier.size(0.px, CurveCellHeight.px).grow(1f).input(hoverable = true, clickable = true) + .cursor(UiCursorShape.HAND).tooltipOnHover(vfxText("curve_open")).onPress { event -> + editing = true + if (property != null) focus(property) + event.consume() + }, + ) + + if (editing) VfxCurveDialog(value, onChange = onChange, onClose = { editing = false }) +} + +@Composable +private fun KindPicker(value: VfxValue, onChange: (VfxValue) -> Unit) { + var open by remember { mutableStateOf(false) } + var anchor by remember { mutableStateOf(UiRect.Zero) } + val kind = value.kind() + + Box(modifier = Modifier.onPlaced { anchor = it }) { + InspectorIcon(kind.icon, vfxText(kind.key)) { open = true } + } + + if (!open) return + ContextMenu( + id = "vfx-kind-$kind", + anchorBounds = anchor, + items = VfxValueKind.entries.map { entry -> + UiDropdownItem(vfxText(entry.key), icon = entry.icon, checked = entry == kind) { + onChange(value.asKind(entry)) + } + }, + onExpandedChange = { if (!it) open = false }, + ) +} + +@Composable +internal fun VfxSourcePicker(source: VfxCurveInput, onChange: (VfxCurveInput) -> Unit) { + var open by remember { mutableStateOf(false) } + var anchor by remember { mutableStateOf(UiRect.Zero) } + + Box(modifier = Modifier.onPlaced { anchor = it }) { + InspectorButton(vfxText("source_${source.name.lowercase()}")) { open = true } + } + if (!open) return + + ContextMenu( + id = "vfx-source", + anchorBounds = anchor, + items = VfxCurveInput.entries.map { entry -> + UiDropdownItem(vfxText("source_${entry.name.lowercase()}"), checked = entry == source) { onChange(entry) } + }, + onExpandedChange = { if (!it) open = false }, + ) +} + +@Composable +internal fun VfxCurvePreview(curve: VfxCurve, modifier: Modifier) { + Box( + modifier = modifier.drawBehind(key = curve) { + val width = size.width + val height = size.height + if (width <= 0f || height <= 0f) return@drawBehind + + drawRect(UiRect(0f, 0f, width, height), UiPaint.Color(CurveBackground), radius = 3f) + + val keys = curve.keys + if (keys.isEmpty()) return@drawBehind + + val start = keys.first().time + val span = (keys.last().time - start).takeIf { it > 1.0e-4f } ?: 1f + var minimum = keys.minOf { it.value } + var maximum = keys.maxOf { it.value } + if (maximum - minimum < 1.0e-4f) { + minimum -= 0.5f + maximum += 0.5f + } + + val inset = 2f + val usable = height - inset * 2f + val columnWidth = width / CurvePreviewSteps + var previousY = Float.NaN + for (step in 0 until CurvePreviewSteps) { + val t = step.toFloat() / (CurvePreviewSteps - 1) + val sampled = curve.valueAt(start + span * t) + val y = inset + usable - ((sampled - minimum) / (maximum - minimum)).coerceIn(0f, 1f) * usable + val top = if (previousY.isNaN()) y else minOf(previousY, y) + val bottom = if (previousY.isNaN()) y else maxOf(previousY, y) + drawRect( + UiRect(step * columnWidth, top, maxOf(columnWidth, 1f), maxOf(bottom - top, 1.5f)), + UiPaint.Color(CurveLine), + ) + previousY = y + } + }, + ) +} + +@Composable +internal fun InspectorIcon(icon: String, tooltip: String, onClick: () -> Unit) { + InspectorIconButton(icon = icon, tooltip = tooltip, tags = listOf("insp-inline-icon"), onClick = onClick) +} + +/** Highlighting, completion and diagnostics for the expressions of the effect dialect. */ +internal val VfxExpressionEditing = ExpressionEditing(VfxExpressionLanguage) + +internal fun vfxText(name: String): String = "hollowengine.gui.vfx.$name".lang + +internal const val FieldLabelWidth = 84f + +/** Vectors carry three fields, so their short labels ("Position") get less of the line. */ +internal const val VectorLabelWidth = 48f + +internal const val CurveCellHeight = 15f +private const val CurvePreviewSteps = 40 +private const val KeyframeIcon = "hollowengine:textures/gui/icons/vfx/key.svg" + +internal val HintTint = UiColor(0.55f, 0.58f, 0.65f, 1f) +internal val CurveBackground = UiColor(0.09f, 0.1f, 0.11f, 1f) +private val CurveLine = UiColor(0.85f, 0.75f, 0.45f, 1f) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxInspector.kt new file mode 100644 index 000000000..30f6224d0 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxInspector.kt @@ -0,0 +1,239 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.client.ui.Checkbox +import ru.hollowhorizon.hollowengine.client.ui.HollowUiContent +import ru.hollowhorizon.hollowengine.client.ui.Image +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Row +import ru.hollowhorizon.hollowengine.client.ui.TextField +import ru.hollowhorizon.hollowengine.client.ui.UiLength +import ru.hollowhorizon.hollowengine.client.ui.grow +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.inspector.CollapsibleSection +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget +import ru.hollowhorizon.hollowengine.client.ui.inspector.Readonly +import ru.hollowhorizon.hollowengine.client.ui.inspector.ToggleRow +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.vfx.* + +/** What the shared inspector shows for the selected node of an effect. */ +internal fun vfxNodeInspector( + document: HollowIdeVfxDocument, + state: VfxEditorState, + nodeId: String, +): InspectorTarget { + val node = document.effect.node(nodeId) + val type = node?.let(VfxNodeTypes::of) + return InspectorTarget( + id = "vfx-node-$nodeId", + title = node?.name ?: nodeId, + subtitle = type?.titleKey?.lang, + icon = type?.icon, + ) { + val live = document.effect.node(nodeId) ?: return@InspectorTarget + CompositionLocalProvider( + LocalVfxFieldFocus provides { property -> state.focusProperty(nodeId, property) }, + LocalVfxDriven provides vfxDrivenLookup(document, state.preview, nodeId), + ) { + NodeFields(document, state, live) + } + } +} + +internal fun HollowIdeVfxDocument.replace(node: VfxNodeSpec) = edit(mergeKey = "node:${node.id}") { it.withNode(node) } + +@Composable +private fun NodeFields(document: HollowIdeVfxDocument, state: VfxEditorState, node: VfxNodeSpec) { + NodeHead(document, node) + + Folding(state, "transform", vfxText("section_transform"), VfxIcons.TRANSFORM) { + val transform = node.transform + VfxFloat3Row(vfxText("position"), transform.position, VfxProperty.POSITION) { + document.replace(node.withCommon(transform = transform.copy(position = it))) + } + VfxFloat3Row(vfxText("rotation"), transform.rotation, VfxProperty.ROTATION) { + document.replace(node.withCommon(transform = transform.copy(rotation = it))) + } + VfxFloat3Row(vfxText("scale"), transform.scale, VfxProperty.SCALE) { + document.replace(node.withCommon(transform = transform.copy(scale = it))) + } + } + + if (node is VfxParticleRendererSpec && document.effect.parentOf(node.id) is VfxEmitterSpec) { + ParticleFields(document, state, node) + } + + when (node) { + is VfxEmitterSpec -> EmitterFields(document, state, node, underEmitter = document.effect.parentOf(node.id) is VfxEmitterSpec) + is VfxPlaneSpec -> PlaneFields(document, state, node) + is VfxMeshSpec -> MeshFields(document, state, node) + is VfxModelSpec -> ModelFields(document, state, node) + is VfxTrailSpec -> TrailFields(document, state, node) + is VfxBeamSpec -> BeamFields(document, state, node) + is VfxPostEffectSpec -> PostEffectFields(document, state, node) + is VfxSkySpec -> SkyFields(document, state, node) + is VfxCameraShakeSpec -> CameraShakeFields(document, state, node) + else -> Unit + } +} + +/** + * Whether the node is on and what it is called, on one line above the sections; a renderer also says + * whether it draws on particles or once, which depends on where it sits in the tree. + */ +@Composable +private fun NodeHead(document: HollowIdeVfxDocument, node: VfxNodeSpec) { + var draft by remember(node.id, node.name) { mutableStateOf(node.name) } + Row(tags = listOf("vfx-node-head")) { + Checkbox( + checked = node.enabled, + id = "vfx-node-enabled-${node.id}", + tags = listOf("insp-checkbox"), + modifier = Modifier.tooltipOnHover(vfxText("enabled")), + onCheckedChange = { document.replace(node.withCommon(enabled = it)) }, + ) + TextField( + value = draft, + id = "vfx-node-name-${node.id}", + fontSize = 9f, + placeholder = vfxText("name"), + tags = listOf("insp-input", "insp-inline-input"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f), + onChange = { typed -> + draft = typed + val trimmed = typed.trim() + if (trimmed.isNotEmpty() && trimmed != node.name) document.replace(node.withCommon(name = trimmed)) + }, + ) + if (node is VfxParticleRendererSpec) { + val onParticles = document.effect.parentOf(node.id) is VfxEmitterSpec + Image( + if (onParticles) VfxIcons.PARTICLE else VfxIcons.SPAWN_ONCE, + tags = listOfNotNull("vfx-placement", "active".takeIf { onParticles }), + modifier = Modifier.tooltipOnHover(vfxText(if (onParticles) "renders_particles" else "renders_once")), + ) + } + } +} + +/** + * What a renderer gives each particle of its emitter. A trail has a width of its own and does not + * turn, so it only offers the color. + */ +@Composable +private fun ParticleFields(document: HollowIdeVfxDocument, state: VfxEditorState, node: VfxParticleRendererSpec) { + val look = node.particle + fun update(next: VfxAppearance) = document.replace(node.withParticle(next)) + + Folding(state, "particle", vfxText("section_particle"), VfxIcons.PARTICLE) { + if (node !is VfxTrailSpec) { + ToggleRow(vfxText("uniform_size"), look.uniformSize) { update(look.copy(uniformSize = it)) } + VfxVec3Row(vfxText("size"), look.size, VfxProperty.SIZE, vfxText("size_hint"), uniform = look.uniformSize) { + update(look.copy(size = it)) + } + VfxVec3Row(vfxText("spin"), look.rotation, VfxProperty.SPIN, vfxText("spin_hint")) { + update(look.copy(rotation = it)) + } + } + VfxColorRow(vfxText("color"), look.color, VfxProperty.COLOR, vfxText("color_hint")) { + update(look.copy(color = it)) + } + } +} + +/** + * A section that remembers whether it is open per effect editor. Every section starts closed, so a + * node opens as a list of what it has rather than a wall of fields. + */ +@Composable +internal fun Folding( + state: VfxEditorState, + key: String, + title: String, + icon: String, + trailing: HollowUiContent? = null, + content: @Composable () -> Unit, +) { + CollapsibleSection( + title = title, + expanded = state.isSectionOpen(key), + id = "vfx-section-$key", + icon = icon, + trailing = trailing, + onToggle = { state.toggleSection(key) }, + ) { + content() + } +} + +/** What the inspector shows for the effect itself, the root of the tree. */ +internal fun vfxEffectInspector(document: HollowIdeVfxDocument, state: VfxEditorState): InspectorTarget = + InspectorTarget(id = "vfx-effect", title = vfxText("effect"), icon = VfxIcons.EFFECT) { + val effect = document.effect + Folding(state, "timeline", vfxText("section_timeline"), VfxIcons.TIMELINE) { + VfxNumberRow( + vfxText("timeline_duration"), + effect.timeline.duration, + vfxText("timeline_duration_hint"), + min = 0.1f + ) { duration -> + document.edit { it.copy(timeline = it.timeline.copy(duration = duration)) } + } + ToggleRow(vfxText("timeline_loop"), effect.timeline.loop) { loop -> + document.edit { it.copy(timeline = it.timeline.copy(loop = loop)) } + } + } + Folding(state, "stats", vfxText("section_stats"), VfxIcons.STATS) { + Readonly(vfxText("nodes"), effect.walk().size.toString()) + Readonly(vfxText("expressions"), effect.expressions().size.toString()) + } + } + +/** The icons of the effect editor, one family drawn for it. */ +internal object VfxIcons { + private const val ROOT = "hollowengine:textures/gui/icons/vfx/" + + const val EFFECT = ROOT + "node_effect.svg" + const val TRANSFORM = ROOT + "transform.svg" + const val PARTICLE = ROOT + "particle.svg" + const val SPAWN_ONCE = ROOT + "key.svg" + const val EMISSION = ROOT + "emission.svg" + const val SUB_EMITTER = ROOT + "sub_emitter.svg" + const val SHAPE = ROOT + "shape.svg" + const val SPAWN = ROOT + "spawn.svg" + const val MOTION = ROOT + "motion.svg" + const val SPACE = ROOT + "space.svg" + const val MODULES = ROOT + "modules.svg" + const val FORCE = ROOT + "force.svg" + const val NOISE = ROOT + "noise.svg" + const val VELOCITY = ROOT + "velocity.svg" + const val COLLISION = ROOT + "collision.svg" + const val FLIPBOOK = ROOT + "flipbook.svg" + const val FACING = ROOT + "facing.svg" + const val MESH = ROOT + "mesh.svg" + const val MODEL = ROOT + "model.svg" + const val MATERIAL = ROOT + "material.svg" + const val SHADER = ROOT + "shader.svg" + const val TRAIL = ROOT + "trail.svg" + const val BEAM = ROOT + "beam.svg" + const val POST = ROOT + "post.svg" + const val SKY = ROOT + "sky.svg" + const val SHAKE = ROOT + "shake.svg" + const val TIMELINE = ROOT + "timeline.svg" + const val STATS = ROOT + "stats.svg" + const val ADD = ROOT + "add.svg" + const val REMOVE = ROOT + "remove.svg" + const val DUPLICATE = ROOT + "duplicate.svg" + const val RENAME = ROOT + "rename.svg" + const val TOGGLE = ROOT + "toggle.svg" + const val MORE = ROOT + "more.svg" +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxMaterialPreview.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxMaterialPreview.kt new file mode 100644 index 000000000..980402d10 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxMaterialPreview.kt @@ -0,0 +1,73 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.isActive +import ru.hollowhorizon.hollowengine.client.ui.Box +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.drawBehind +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import ru.hollowhorizon.hollowengine.common.vfx.VfxFacing +import ru.hollowhorizon.hollowengine.common.vfx.VfxMeshSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxNodeSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPlaneSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxSurfaceSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTransform + +/** + * The material being edited on its own, turning slowly in an empty view. + */ +@Composable +internal fun VfxMaterialPreview(state: VfxEditorState, surface: VfxSurfaceSpec) { + val preview = state.materialPreview + val effect = remember(surface) { VfxEffect(nodes = listOf(previewNode(surface))) } + val current by rememberUpdatedState(effect) + + LaunchedEffect(preview) { + while (isActive) { + withFrameNanos { frameNanos -> + preview.sync(current, PreviewAsset) + val dt = preview.deltaSinceLastFrame(frameNanos) + if (dt > 0f) { + preview.advanceBy(dt) + preview.yaw = (preview.yaw + dt * TurnDegreesPerSecond) % 360f + } + } + } + } + + Box( + id = "vfx-material-preview", + tags = listOf("vfx-material-preview"), + modifier = Modifier.drawBehind(key = preview) { + drawGl { preview.render(rect, poseStack) } + }, + ) +} + +/** The node the preview draws: [surface] alone, where the camera looks, with nothing under it. */ +private fun previewNode(surface: VfxSurfaceSpec): VfxNodeSpec = when (surface) { + is VfxMeshSpec -> surface.withCommon(transform = VfxTransform.IDENTITY, children = emptyList(), enabled = true) + is VfxPlaneSpec -> surface.copy( + transform = VfxTransform.IDENTITY, + children = emptyList(), + enabled = true, + facing = VfxFacing.CAMERA, + ) + + else -> VfxPlaneSpec(material = surface.material, tint = surface.tint) +} + +/** A preview camera that frames a one block surface at the origin. */ +internal fun materialPreviewState(): VfxPreviewState = VfxPreviewState(initialDistance = 1.9f).apply { + targetY = 0f + pitch = 18f + showFloor = false +} + +private const val PreviewAsset = "hollowengine:vfx/material_preview" +private const val TurnDegreesPerSecond = 24f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxModuleInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxModuleInspector.kt new file mode 100644 index 000000000..a316126c1 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxModuleInspector.kt @@ -0,0 +1,224 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import ru.hollowhorizon.hollowengine.client.ui.Box +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.inspector.Hint +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorButton +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorIconButton +import ru.hollowhorizon.hollowengine.client.ui.inspector.Pills +import ru.hollowhorizon.hollowengine.client.ui.inspector.ToggleRow +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.onPlaced +import ru.hollowhorizon.hollowengine.client.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxCollisionAction +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxCollisionSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmitterSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceKind +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxModuleSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxModuleTypes +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxNoiseSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxSimulationSpace +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvAnimationSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvMode +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxVelocityOverLifetimeSpec +import kotlin.reflect.KClass + +/** + * The modules of an emitter, one folding section each, and a menu of what can still be added. + */ +@Composable +internal fun VfxModuleSections(document: HollowIdeVfxDocument, state: VfxEditorState, emitter: VfxEmitterSpec) { + emitter.modules.forEachIndexed { index, module -> + val type = VfxModuleTypes.of(module) + val name = type?.titleKey?.lang ?: module.id + val title = if (module.enabled) name else "$name (${vfxText("off")})" + Folding( + state, + "module-${emitter.id}-${module.id}", + title, + moduleIcon(module), + trailing = { + InspectorIconButton( + VfxIcons.TOGGLE, + vfxText("module_enabled"), + active = module.enabled, + tags = listOf("insp-inline-icon"), + ) { document.replaceModule(emitter, index, module.withEnabled(!module.enabled)) } + InspectorIconButton( + VfxIcons.REMOVE, + vfxText("module_remove"), + tags = listOf("insp-inline-icon", "danger"), + ) { document.removeModule(emitter, index) } + }, + ) { + ModuleFields(document, emitter, index, module) + } + } + + AddModuleButton(document, emitter) +} + +@Composable +private fun AddModuleButton(document: HollowIdeVfxDocument, emitter: VfxEmitterSpec) { + val addable = VfxModuleTypes.forEmitter(emitter) + .filter { it.createDefault != null } + .filter { type -> type.repeatable || emitter.modules.none { type.specClass.isInstance(it) } } + if (addable.isEmpty()) return + + var open by remember { mutableStateOf(false) } + var anchor by remember { mutableStateOf(UiRect.Zero) } + + Box(modifier = Modifier.size(100.percent).onPlaced { anchor = it }) { + InspectorButton(vfxText("module_add"), icon = VfxIcons.ADD) { open = true } + } + if (!open) return + + ContextMenu( + id = "vfx-add-module", + anchorBounds = anchor, + items = addable.map { type -> + UiDropdownItem(type.titleKey.lang, icon = moduleIcon(type.specClass)) { + type.createDefault?.invoke()?.let { document.addModule(emitter, it) } + } + }, + onExpandedChange = { if (!it) open = false }, + ) +} + +private fun moduleIcon(module: VfxModuleSpec): String = moduleIcon(module::class) + +private fun moduleIcon(kind: KClass): String = when (kind) { + VfxVelocityOverLifetimeSpec::class -> VfxIcons.VELOCITY + VfxForceSpec::class -> VfxIcons.FORCE + VfxNoiseSpec::class -> VfxIcons.NOISE + VfxCollisionSpec::class -> VfxIcons.COLLISION + VfxUvAnimationSpec::class -> VfxIcons.FLIPBOOK + else -> VfxIcons.MODULES +} + +@Composable +private fun ModuleFields( + document: HollowIdeVfxDocument, + emitter: VfxEmitterSpec, + index: Int, + module: VfxModuleSpec, +) { + fun replace(next: VfxModuleSpec) = document.replaceModule(emitter, index, next) + fun property(field: String) = VfxProperty.module(module.id, field) + + when (module) { + is VfxVelocityOverLifetimeSpec -> { + Pills(VfxSimulationSpace.entries, module.space, { vfxText("space_${it.name.lowercase()}") }) { + replace(module.copy(space = it)) + } + VfxVec3Row(vfxText("velocity"), module.velocity, property("velocity"), vfxText("velocity_hint")) { + replace(module.copy(velocity = it)) + } + } + + is VfxForceSpec -> { + Pills(VfxForceKind.entries, module.kind, { vfxText("force_${it.name.lowercase()}") }) { + replace(module.copy(kind = it)) + } + VfxValueRow(vfxText("strength"), module.strength, property("strength"), vfxText("force_strength_hint")) { + replace(module.copy(strength = it)) + } + if (module.kind == VfxForceKind.DIRECTIONAL || module.kind == VfxForceKind.VORTEX) { + VfxFloat3Row(vfxText("direction"), module.direction) { replace(module.copy(direction = it)) } + } + if (module.kind != VfxForceKind.DIRECTIONAL) { + VfxFloat3Row(vfxText("force_center"), module.center, hint = vfxText("force_center_hint")) { + replace(module.copy(center = it)) + } + } + VfxValueRow(vfxText("radius"), module.radius, property("radius"), vfxText("force_radius_hint")) { + replace(module.copy(radius = it)) + } + ToggleRow(vfxText("falloff"), module.falloff) { replace(module.copy(falloff = it)) } + } + + is VfxNoiseSpec -> { + VfxValueRow(vfxText("strength"), module.strength, property("strength")) { replace(module.copy(strength = it)) } + VfxValueRow(vfxText("frequency"), module.frequency, property("frequency"), vfxText("frequency_hint")) { + replace(module.copy(frequency = it)) + } + VfxNumberRow(vfxText("scroll_speed"), module.scrollSpeed, vfxText("scroll_speed_hint")) { + replace(module.copy(scrollSpeed = it)) + } + VfxIntRow(vfxText("octaves"), module.octaves, vfxText("octaves_hint"), min = 1, max = 4) { + replace(module.copy(octaves = it)) + } + } + + is VfxCollisionSpec -> { + Pills(VfxCollisionAction.entries, module.action, { vfxText("hit_${it.name.lowercase()}") }) { + replace(module.copy(action = it)) + } + if (module.action == VfxCollisionAction.BOUNCE) { + VfxNumberRow(vfxText("bounce"), module.bounce, vfxText("bounce_hint"), min = 0f, max = 1f) { + replace(module.copy(bounce = it)) + } + } + if (module.action != VfxCollisionAction.DIE) { + VfxNumberRow(vfxText("friction"), module.friction, vfxText("friction_hint"), min = 0f, max = 1f) { + replace(module.copy(friction = it)) + } + VfxNumberRow(vfxText("lifetime_loss"), module.lifetimeLoss, vfxText("lifetime_loss_hint"), min = 0f) { + replace(module.copy(lifetimeLoss = it)) + } + } + VfxNumberRow(vfxText("hit_radius"), module.radius, vfxText("hit_radius_hint"), min = 0f, max = 1f) { + replace(module.copy(radius = it)) + } + } + + is VfxUvAnimationSpec -> { + VfxIntRow(vfxText("uv_columns"), module.columns, min = 1, max = 64) { replace(module.copy(columns = it)) } + VfxIntRow(vfxText("uv_rows"), module.rows, min = 1, max = 64) { replace(module.copy(rows = it)) } + VfxIntRow(vfxText("uv_frames"), module.frames, vfxText("uv_frames_hint"), min = 0, max = 4096) { + replace(module.copy(frames = it)) + } + Pills(VfxUvMode.entries, module.mode, { vfxText("uv_${it.name.lowercase()}") }) { + replace(module.copy(mode = it)) + } + when (module.mode) { + VfxUvMode.FPS -> VfxNumberRow(vfxText("fps"), module.fps, min = 0f) { replace(module.copy(fps = it)) } + VfxUvMode.OVER_LIFETIME -> VfxNumberRow(vfxText("cycles"), module.cycles, vfxText("cycles_hint"), min = 0f) { + replace(module.copy(cycles = it)) + } + + VfxUvMode.RANDOM_FRAME -> Unit + } + } + + else -> Hint(vfxText("module_unknown")) + } +} + +private fun HollowIdeVfxDocument.replaceModule(emitter: VfxEmitterSpec, index: Int, module: VfxModuleSpec) { + val modules = emitter.modules.toMutableList() + if (index !in modules.indices) return + modules[index] = module + edit(mergeKey = "node:${emitter.id}") { it.withNode(emitter.copy(modules = modules)) } +} + +private fun HollowIdeVfxDocument.removeModule(emitter: VfxEmitterSpec, index: Int) { + val modules = emitter.modules.filterIndexed { at, _ -> at != index } + edit(mergeKey = "node:${emitter.id}") { it.withNode(emitter.copy(modules = modules)) } +} + +private fun HollowIdeVfxDocument.addModule(emitter: VfxEmitterSpec, module: VfxModuleSpec) { + edit { it.withNode(emitter.copy(modules = emitter.modules + module)) } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewGizmo.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewGizmo.kt new file mode 100644 index 000000000..d196f12c7 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewGizmo.kt @@ -0,0 +1,334 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import org.joml.Matrix4f +import org.joml.Vector3f +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.vfx.VfxFrame +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeRuntime +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.* +import kotlin.math.* + +/** One line of the gizmo, already in panel pixels. */ +data class VfxGizmoLine(val x0: Float, val y0: Float, val x1: Float, val y1: Float, val color: UiColor) + +/** + * A draggable point of the gizmo. + */ +class VfxGizmoHandle( + val id: String, + val x: Float, + val y: Float, + val screenAxisX: Float, + val screenAxisY: Float, + val color: UiColor, + val readout: (Float) -> Float, + val apply: (VfxNodeSpec, Float) -> VfxNodeSpec, +) + +class VfxGizmo(val lines: List, val handles: List) { + fun handleAt(x: Float, y: Float): VfxGizmoHandle? = + handles.filter { (it.x - x) * (it.x - x) + (it.y - y) * (it.y - y) <= PICK_RADIUS * PICK_RADIUS } + .minByOrNull { (it.x - x) * (it.x - x) + (it.y - y) * (it.y - y) } + + companion object { + val EMPTY = VfxGizmo(emptyList(), emptyList()) + const val PICK_RADIUS = 7f + } +} + +/** + * Builds the handles of an emitter's shape as the preview camera sees it. Placement is the world + * transform gizmo's job ([VfxTransformGizmo]). + */ +object VfxGizmos { + fun build( + preview: VfxPreviewState, + node: VfxNodeSpec, + runtime: VfxNodeRuntime?, + showShape: Boolean, + driven: (VfxProperty) -> VfxDrivenValue?, + ): VfxGizmo { + val width = preview.viewportWidth + val height = preview.viewportHeight + if (width <= 1f || height <= 1f || runtime == null) return VfxGizmo.EMPTY + + val builder = Builder(preview, preview.panelMatrix(width, height), runtime.frame, driven) + if (showShape && node is VfxEmitterSpec) builder.shape(node.shape) + return VfxGizmo(builder.lines, builder.handles) + } + + /** + * How far a drag of ([dx], [dy]) pixels moves [handle], in its own units: Shift is ten times finer, Ctrl rounds to a step. + */ + fun dragDistance(handle: VfxGizmoHandle, dx: Float, dy: Float, fine: Boolean, snap: Boolean): Float { + val lengthSquared = handle.screenAxisX * handle.screenAxisX + handle.screenAxisY * handle.screenAxisY + if (lengthSquared < 1.0e-4f) return 0f + var distance = (dx * handle.screenAxisX + dy * handle.screenAxisY) / lengthSquared + if (fine) distance *= FINE_FACTOR + if (snap) { + val tick = if (fine) FINE_TICK else TICK + distance = round(distance / tick) * tick + } + return distance + } + + private class Builder( + private val preview: VfxPreviewState, + private val matrix: Matrix4f, + private val frame: VfxFrame, + private val driven: (VfxProperty) -> VfxDrivenValue?, + ) { + val lines = ArrayList() + val handles = ArrayList() + + private val point = MutableVec3f() + private val basis = preview.cameraBasis() + + private fun world(x: Float, y: Float, z: Float): Vector3f { + frame.transformPoint(Vec3f(x, y, z), point) + return Vector3f(point.x, point.y, point.z) + } + + fun segment(a: Vector3f, b: Vector3f, color: UiColor) { + val (start, end) = clipToNear(a, b) ?: return + val from = preview.project(matrix, start) ?: return + val to = preview.project(matrix, end) ?: return + if (!sane(from) || !sane(to)) return + lines += VfxGizmoLine(from.x, from.y, to.x, to.y, color) + } + + private fun clipToNear(a: Vector3f, b: Vector3f): Pair? { + val da = Vector3f(a).sub(basis.eye).dot(basis.forward) - NEAR_CLIP + val db = Vector3f(b).sub(basis.eye).dot(basis.forward) - NEAR_CLIP + if (da < 0f && db < 0f) return null + if (da >= 0f && db >= 0f) return a to b + val t = da / (da - db) + val cut = Vector3f(a).lerp(b, t) + return if (da < 0f) cut to b else a to cut + } + + private fun sane(point: Vector3f): Boolean = + point.x in -SCREEN_LIMIT..SCREEN_LIMIT && point.y in -SCREEN_LIMIT..SCREEN_LIMIT + + private fun polyline(points: List, color: UiColor, closed: Boolean) { + for (index in 0 until points.size - 1) segment(points[index], points[index + 1], color) + if (closed && points.size > 2) segment(points.last(), points.first(), color) + } + + private fun circle(radius: Float, plane: Int, height: Float, color: UiColor) { + val points = (0 until CIRCLE_SEGMENTS).map { index -> + val angle = index * (2.0 * PI / CIRCLE_SEGMENTS).toFloat() + val u = cos(angle) * radius + val v = sin(angle) * radius + when (plane) { + PLANE_XZ -> world(u, height, v) + PLANE_XY -> world(u, v, height) + else -> world(height, u, v) + } + } + polyline(points, color, closed = true) + } + + private fun handle( + id: String, + local: Vector3f, + direction: Vector3f, + color: UiColor, + readout: (Float) -> Float, + apply: (VfxNodeSpec, Float) -> VfxNodeSpec, + ) { + val base = world(local.x, local.y, local.z) + val tip = world(local.x + direction.x, local.y + direction.y, local.z + direction.z) + val from = preview.project(matrix, base) ?: return + val to = preview.project(matrix, tip) ?: return + if (!sane(from)) return + handles += VfxGizmoHandle(id, from.x, from.y, to.x - from.x, to.y - from.y, color, readout, apply) + } + + private fun current(property: VfxProperty, channel: Int, value: VfxValue, fallback: Float): Float { + val keyed = driven(property)?.takeIf { it.drives(channel) } + return keyed?.value(channel) ?: value.constantOr(fallback) + } + + private fun editable(property: VfxProperty, channel: Int, value: VfxValue): Boolean = + value is VfxValue.Const && driven(property)?.drives(channel) != true + + fun shape(shape: VfxShape) { + val radius = current(VfxProperty.SHAPE_RADIUS, 0, shape.radius, 0.5f) + val thickness = current(VfxProperty.SHAPE_THICKNESS, 0, shape.thickness, 1f) + val radiusEditable = editable(VfxProperty.SHAPE_RADIUS, 0, shape.radius) + + fun radiusHandle() { + if (!radiusEditable) return + handle( + "radius", Vector3f(radius, 0f, 0f), Vector3f(1f, 0f, 0f), HandleColor, + readout = { (radius + it).coerceAtLeast(0f) }, + ) { node, delta -> + node.withShape { it.copy(radius = VfxValue.Const((radius + delta).coerceAtLeast(0f))) } + } + } + + when (shape.kind) { + VfxShapeKind.POINT, VfxShapeKind.MODEL -> { + val size = 0.08f + segment(world(-size, 0f, 0f), world(size, 0f, 0f), ShapeColor) + segment(world(0f, -size, 0f), world(0f, size, 0f), ShapeColor) + segment(world(0f, 0f, -size), world(0f, 0f, size), ShapeColor) + } + + VfxShapeKind.SPHERE -> { + circle(radius, PLANE_XZ, 0f, ShapeColor) + circle(radius, PLANE_XY, 0f, ShapeColor) + circle(radius, PLANE_YZ, 0f, ShapeColor) + if (thickness < 1f) circle(radius * (1f - thickness), PLANE_XZ, 0f, ShapeFaint) + radiusHandle() + } + + VfxShapeKind.DISC -> { + circle(radius, PLANE_XZ, 0f, ShapeColor) + if (thickness < 1f) circle(radius * (1f - thickness), PLANE_XZ, 0f, ShapeFaint) + radiusHandle() + } + + VfxShapeKind.CONE -> cone(shape, radius) + VfxShapeKind.BOX -> box(shape) + VfxShapeKind.LINE -> line(shape) + } + } + + private fun cone(shape: VfxShape, radius: Float) { + val angle = current(VfxProperty.SHAPE_ANGLE, 0, shape.angle, 25f).coerceIn(0f, 89f) + val top = radius + tan(Math.toRadians(angle.toDouble())).toFloat() * CONE_PREVIEW_HEIGHT + circle(radius, PLANE_XZ, 0f, ShapeColor) + circle(top, PLANE_XZ, CONE_PREVIEW_HEIGHT, ShapeFaint) + for (index in 0 until 4) { + val around = index * (PI / 2.0).toFloat() + segment( + world(cos(around) * radius, 0f, sin(around) * radius), + world(cos(around) * top, CONE_PREVIEW_HEIGHT, sin(around) * top), + ShapeFaint, + ) + } + if (editable(VfxProperty.SHAPE_RADIUS, 0, shape.radius)) { + handle( + "radius", Vector3f(radius, 0f, 0f), Vector3f(1f, 0f, 0f), HandleColor, + readout = { (radius + it).coerceAtLeast(0f) }, + ) { node, delta -> + node.withShape { it.copy(radius = VfxValue.Const((radius + delta).coerceAtLeast(0f))) } + } + } + if (editable(VfxProperty.SHAPE_ANGLE, 0, shape.angle)) { + fun degreesFor(delta: Float): Float { + val spread = ((top + delta - radius) / CONE_PREVIEW_HEIGHT).coerceAtLeast(0f) + return Math.toDegrees(atan(spread).toDouble()).toFloat().coerceIn(0f, 89f) + } + handle( + "angle", Vector3f(top, CONE_PREVIEW_HEIGHT, 0f), Vector3f(1f, 0f, 0f), AngleColor, + readout = ::degreesFor, + ) { node, delta -> node.withShape { it.copy(angle = VfxValue.Const(degreesFor(delta))) } } + } + } + + private fun box(shape: VfxShape) { + val extents = shape.extents + val x = current(VfxProperty.SHAPE_EXTENTS, 0, extents.x, 0.5f) + val y = current(VfxProperty.SHAPE_EXTENTS, 1, extents.y, 0.5f) + val z = current(VfxProperty.SHAPE_EXTENTS, 2, extents.z, 0.5f) + val corners = listOf( + world(-x, -y, -z), world(x, -y, -z), world(x, -y, z), world(-x, -y, z), + world(-x, y, -z), world(x, y, -z), world(x, y, z), world(-x, y, z), + ) + polyline(corners.subList(0, 4), ShapeColor, closed = true) + polyline(corners.subList(4, 8), ShapeColor, closed = true) + for (index in 0 until 4) segment(corners[index], corners[index + 4], ShapeColor) + + if (editable(VfxProperty.SHAPE_EXTENTS, 0, extents.x)) { + handle( + "extent-x", + Vector3f(x, 0f, 0f), + Vector3f(1f, 0f, 0f), + AxisX, + { (x + it).coerceAtLeast(0f) }) { node, delta -> + node.withShape { it.copy(extents = it.extents.copy(x = VfxValue.Const((x + delta).coerceAtLeast(0f)))) } + } + } + if (editable(VfxProperty.SHAPE_EXTENTS, 1, extents.y)) { + handle( + "extent-y", + Vector3f(0f, y, 0f), + Vector3f(0f, 1f, 0f), + AxisY, + { (y + it).coerceAtLeast(0f) }) { node, delta -> + node.withShape { it.copy(extents = it.extents.copy(y = VfxValue.Const((y + delta).coerceAtLeast(0f)))) } + } + } + if (editable(VfxProperty.SHAPE_EXTENTS, 2, extents.z)) { + handle( + "extent-z", + Vector3f(0f, 0f, z), + Vector3f(0f, 0f, 1f), + AxisZ, + { (z + it).coerceAtLeast(0f) }) { node, delta -> + node.withShape { it.copy(extents = it.extents.copy(z = VfxValue.Const((z + delta).coerceAtLeast(0f)))) } + } + } + } + + private fun line(shape: VfxShape) { + val extents = shape.extents + val end = Vector3f( + current(VfxProperty.SHAPE_EXTENTS, 0, extents.x, 0.5f), + current(VfxProperty.SHAPE_EXTENTS, 1, extents.y, 0.5f), + current(VfxProperty.SHAPE_EXTENTS, 2, extents.z, 0.5f), + ) + segment(world(0f, 0f, 0f), world(end.x, end.y, end.z), ShapeColor) + + val length = end.length() + val editable = (0..2).all { channel -> + editable(VfxProperty.SHAPE_EXTENTS, channel, listOf(extents.x, extents.y, extents.z)[channel]) + } + if (!editable || length <= 1.0e-4f) return + + val direction = Vector3f(end).div(length) + handle("line-end", end, direction, HandleColor, { (length + it).coerceAtLeast(0f) }) { node, delta -> + val stretched = Vector3f(direction).mul((length + delta).coerceAtLeast(0f)) + node.withShape { + it.copy( + extents = it.extents.copy( + x = VfxValue.Const(stretched.x), + y = VfxValue.Const(stretched.y), + z = VfxValue.Const(stretched.z), + ) + ) + } + } + } + } + + private fun VfxNodeSpec.withShape(change: (VfxShape) -> VfxShape): VfxNodeSpec { + val emitter = this as? VfxEmitterSpec ?: return this + return emitter.copy(shape = change(emitter.shape)) + } + + private const val CIRCLE_SEGMENTS = 40 + private const val PLANE_XZ = 0 + private const val PLANE_XY = 1 + private const val PLANE_YZ = 2 + private const val CONE_PREVIEW_HEIGHT = 1f + private const val NEAR_CLIP = 0.06f + private const val SCREEN_LIMIT = 8000f + + private const val FINE_FACTOR = 0.1f + private const val TICK = 0.1f + private const val FINE_TICK = 0.01f + + private val ShapeColor = UiColor(0.98f, 0.78f, 0.35f, 0.9f) + private val ShapeFaint = UiColor(0.98f, 0.78f, 0.35f, 0.45f) + private val HandleColor = UiColor(1f, 0.9f, 0.55f, 1f) + private val AngleColor = UiColor(0.75f, 0.55f, 1f, 1f) + private val AxisX = UiColor(0.91f, 0.36f, 0.36f, 1f) + private val AxisY = UiColor(0.45f, 0.82f, 0.4f, 1f) + private val AxisZ = UiColor(0.4f, 0.6f, 0.98f, 1f) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewRenderer.kt new file mode 100644 index 000000000..d2ca38b5f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewRenderer.kt @@ -0,0 +1,148 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import com.mojang.blaze3d.pipeline.TextureTarget +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.BufferUploader +import com.mojang.blaze3d.vertex.DefaultVertexFormat +import com.mojang.blaze3d.vertex.PoseStack +import com.mojang.blaze3d.vertex.Tesselator +import com.mojang.blaze3d.vertex.VertexFormat +import com.mojang.blaze3d.vertex.VertexSorting +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.GameRenderer +import org.joml.Matrix4f +import org.joml.Vector3f +import org.joml.Vector4f +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.client.render.DebugLines +import ru.hollowhorizon.hollowengine.client.ui.ide.files.animator.AnimatorColors +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.render.withOwnRenderTarget +import ru.hollowhorizon.hollowengine.client.vfx.VfxInstance +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxDrawList +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxFrameRenderer +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxPostProcessor +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxView +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Draws the preview into a target of its own, as large as the panel is in real pixels, and puts + * that on the panel. + * + * A target of its own is what lets the preview show what the world shows: full-screen passes run + * over the preview alone, and a material that reads the scene depth reads the preview's. + */ +internal class VfxPreviewRenderer { + private var target: TextureTarget? = null + private val frame = VfxDrawList() + + /** The camera shake of the last frame, in degrees; the next frame's camera takes it. */ + val shake = FloatArray(3) + + fun render(preview: VfxPreviewState, instance: VfxInstance, rect: UiRect, stack: PoseStack) { + val size = pixelSize(rect, stack) ?: return + var texture = 0 + + withOwnRenderTarget { + val offscreen = targetOf(size.first, size.second) + texture = offscreen.colorTextureId + offscreen.setClearColor(Background.red, Background.green, Background.blue, 1f) + offscreen.clear(Minecraft.ON_OSX) + offscreen.bindWrite(true) + + val view = preview.viewMatrix(shake) + val projection = preview.perspective(size.first.toFloat(), size.second.toFloat()) + RenderSystem.setProjectionMatrix(projection, VertexSorting.DISTANCE_TO_ORIGIN) + RenderSystem.getModelViewStack().set(view) + RenderSystem.applyModelViewMatrix() + + val buffers = Minecraft.getInstance().renderBuffers().bufferSource() + if (preview.showFloor) { + drawFloor(DebugLines.batch(buffers, PoseStack(), DebugLines.BOUND)) + buffers.endBatch(DebugLines.BOUND) + } + + val eye = preview.cameraBasis().eye + instance.camera.set(eye.x, eye.y, eye.z) + frame.clear() + instance.collect(frame, Matrix4f()) + frame.shake.copyInto(shake) + + VfxFrameRenderer.render( + frame, + VfxView( + modelView = view, + projection = projection, + right = Vector3f(view.m00(), view.m10(), view.m20()), + up = Vector3f(view.m01(), view.m11(), view.m21()), + eye = eye, + ), + offscreen, + ) + VfxPostProcessor.apply(frame.posts, offscreen) + } + + blit(texture, rect, stack) + } + + fun close() { + target?.destroyBuffers() + target = null + } + + /** How many pixels of the target being drawn into the panel covers, whatever scale the UI uses. */ + private fun pixelSize(rect: UiRect, stack: PoseStack): Pair? { + val toClip = Matrix4f(RenderSystem.getProjectionMatrix()).mul(RenderSystem.getModelViewMatrix()) + .mul(stack.last().pose()) + val from = toClip.transform(Vector4f(0f, 0f, 0f, 1f)) + val to = toClip.transform(Vector4f(rect.width, rect.height, 0f, 1f)) + val viewport = IntArray(4).also { GL33.glGetIntegerv(GL33.GL_VIEWPORT, it) } + val width = (abs(to.x / to.w - from.x / from.w) * 0.5f * viewport[2]).roundToInt() + val height = (abs(to.y / to.w - from.y / from.w) * 0.5f * viewport[3]).roundToInt() + if (width < 2 || height < 2) return null + return width.coerceAtMost(MAX_SIZE) to height.coerceAtMost(MAX_SIZE) + } + + private fun targetOf(width: Int, height: Int): TextureTarget { + val current = target + if (current != null && current.width == width && current.height == height) return current + current?.destroyBuffers() + return TextureTarget(width, height, true, Minecraft.ON_OSX).also { target = it } + } + + /** The target over the panel, upside down, since a target starts at the bottom. */ + private fun blit(texture: Int, rect: UiRect, stack: PoseStack) { + RenderSystem.setShader(GameRenderer::getPositionTexShader) + RenderSystem.setShaderTexture(0, texture) + RenderSystem.disableBlend() + RenderSystem.disableCull() + val pose = stack.last().pose() + val builder = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX) + builder.addVertex(pose, 0f, 0f, 0f).setUv(0f, 1f) + builder.addVertex(pose, 0f, rect.height, 0f).setUv(0f, 0f) + builder.addVertex(pose, rect.width, rect.height, 0f).setUv(1f, 0f) + builder.addVertex(pose, rect.width, 0f, 0f).setUv(1f, 1f) + BufferUploader.drawWithShader(builder.buildOrThrow()) + RenderSystem.enableCull() + } + + private fun drawFloor(lines: DebugLines.Batch) { + val half = FLOOR_HALF_SIZE + for (step in -half..half) { + val offset = step.toFloat() + val color = if (step == 0) AXIS_COLOR else GRID_COLOR + lines.line(Vec3f(-half.toFloat(), 0f, offset), Vec3f(half.toFloat(), 0f, offset), color) + lines.line(Vec3f(offset, 0f, -half.toFloat()), Vec3f(offset, 0f, half.toFloat()), color) + } + } + + private companion object { + val Background = AnimatorColors.Canvas + const val MAX_SIZE = 8192 + const val FLOOR_HALF_SIZE = 6 + const val GRID_COLOR = 0x40AFC4E0 + const val AXIS_COLOR = 0x80DCBF73.toInt() + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewState.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewState.kt new file mode 100644 index 000000000..d6f7fe78a --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxPreviewState.kt @@ -0,0 +1,280 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.mojang.blaze3d.vertex.PoseStack +import org.joml.Matrix4f +import org.joml.Vector3f +import org.joml.Vector4f +import ru.hollowhorizon.hollowengine.client.handlers.TickHandler +import ru.hollowhorizon.hollowengine.client.ui.SpringZoom +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.vfx.VfxBudget +import ru.hollowhorizon.hollowengine.client.vfx.VfxInstance +import ru.hollowhorizon.hollowengine.client.vfx.VfxPlaneEnvironment +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.sin + +/** The vertical field of view of the preview camera, in degrees. */ +internal const val VfxPreviewFieldOfView = 50f + +/** + * The preview of the effect being edited: an orbit camera with perspective, a floor, and one + * playing instance. + */ +@Stable +class VfxPreviewState(initialDistance: Float = 4.5f) { + var yaw by mutableStateOf(35f) + var pitch by mutableStateOf(22f) + private val zoomSpring = SpringZoom(initialDistance, MIN_DISTANCE, MAX_DISTANCE, perNotch = ZOOM_PER_NOTCH) + val distance: Float get() = zoomSpring.value + var targetX by mutableStateOf(0f) + var targetY by mutableStateOf(0.7f) + var targetZ by mutableStateOf(0f) + + var showFloor by mutableStateOf(true) + var showShape by mutableStateOf(true) + + /** The viewport size in panel pixels, which gizmo projects into. */ + var viewportWidth by mutableStateOf(0f) + var viewportHeight by mutableStateOf(0f) + + /** How far the effect has been simulated. */ + var time by mutableStateOf(0f) + private set + + /** Bumped whenever [instance] is replaced, so what reads its nodes knows to look again. */ + var revision by mutableStateOf(0) + private set + + /** Particles alive, refreshed by the editor clock rather than read from the instance by the UI. */ + var liveParticles by mutableStateOf(0) + private set + + var instance: VfxInstance? = null + private set + + private var builtFor: VfxEffect? = null + private var lastFrameNanos = 0L + + /** + * Brings the playing instance in line with [effect]. Returns whether it had to be rebuilt. + */ + fun sync(effect: VfxEffect, asset: String): Boolean { + if (builtFor === effect) return false + val playing = instance + val previous = builtFor + builtFor = effect + if (playing != null && previous != null && playing.adoptPlacement(effect)) { + revision++ + return false + } + + val at = time + instance = VfxInstance(effect, asset, VfxPlaneEnvironment(0.0), seed = PREVIEW_SEED).also { it.update(0f) } + revision++ + time = 0f + lastFrameNanos = 0L + if (at in 0f..CATCH_UP_LIMIT) seek(at) else refreshCount() + return true + } + + fun restart() { + instance?.restart() + time = 0f + lastFrameNanos = 0L + refreshCount() + } + + /** + * Seconds since the previous frame of the editor. + */ + fun deltaSinceLastFrame(frameNanos: Long): Float { + val previous = lastFrameNanos + lastFrameNanos = frameNanos + if (previous == 0L) return 0f + return ((frameNanos - previous) / NANOS_PER_SECOND).toFloat().coerceIn(0f, MAX_STEP) + } + + /** + * Moves the effect to [seconds]. + */ + fun seek(seconds: Float) { + val target = seconds.coerceAtLeast(0f) + if (target < time) restart() + + val distance = target - time + if (distance > CATCH_UP_EPSILON) { + val steps = ceil(distance / CATCH_UP_STEP).toInt().coerceIn(1, MAX_CATCH_UP_STEPS) + val step = distance / steps + repeat(steps) { advanceBy(step) } + } + time = target + } + + private var pendingSeek = Float.NaN + + /** + * Asks for [seek] on the next frame. + */ + fun requestSeek(seconds: Float) { + pendingSeek = seconds + } + + /** Runs the seek asked for since the last frame, if any; returns whether there was one. */ + fun applyPendingSeek(): Boolean { + val target = pendingSeek + if (target.isNaN()) return false + pendingSeek = Float.NaN + seek(target) + return true + } + + /** Advances the effect by [dt] seconds. */ + fun advanceBy(dt: Float) { + val playing = instance ?: return + + val eye = cameraBasis().eye + playing.camera.set(eye.x, eye.y, eye.z) + playing.partialTick = TickHandler.partialTick + playing.gameTime = TickHandler.clientFrame + TickHandler.partialTick + playing.budget = VfxBudget.share(1) + playing.update(dt) + time = playing.time + refreshCount() + } + + private fun refreshCount() { + val count = instance?.particleCount ?: 0 + if (count != liveParticles) liveParticles = count + } + + private var dragYaw = 0f + private var dragPitch = 0f + private val dragTarget = Vector3f() + + /** Remembers the camera as it was when a drag started; drag is measured from here. */ + fun beginCameraDrag() { + dragYaw = yaw + dragPitch = pitch + dragTarget.set(targetX, targetY, targetZ) + } + + fun orbit(totalX: Float, totalY: Float) { + yaw = (dragYaw - totalX * ORBIT_SPEED) % 360f + pitch = (dragPitch + totalY * ORBIT_SPEED).coerceIn(-89f, 89f) + } + + /** Slides point, that camera circles around, along the screen, by a share of the distance. */ + fun pan(totalX: Float, totalY: Float) { + val basis = cameraBasis() + val scale = distance * PAN_SPEED + targetX = dragTarget.x + (-basis.right.x * totalX + basis.up.x * totalY) * scale + targetY = dragTarget.y + (-basis.right.y * totalX + basis.up.y * totalY) * scale + targetZ = dragTarget.z + (-basis.right.z * totalX + basis.up.z * totalY) * scale + } + + /** One wheel notch, scrolling up moves the camera back. */ + fun zoom(scrollY: Float) = zoomSpring.scroll(scrollY) + + fun advanceCamera(frameNanos: Long) = zoomSpring.advance(frameNanos) + + class Basis(val eye: Vector3f, val right: Vector3f, val up: Vector3f, val forward: Vector3f) + + /** Where the camera is and where it looks, in effect space. */ + fun cameraBasis(): Basis { + val yawRad = Math.toRadians(yaw.toDouble()).toFloat() + val pitchRad = Math.toRadians(pitch.toDouble()).toFloat() + val back = Vector3f(cos(pitchRad) * sin(yawRad), sin(pitchRad), cos(pitchRad) * cos(yawRad)) + val eye = Vector3f(targetX, targetY, targetZ).add(Vector3f(back).mul(distance)) + val forward = Vector3f(back).negate() + val right = Vector3f(forward).cross(Vector3f(0f, 1f, 0f)).normalize() + val up = Vector3f(right).cross(forward).normalize() + return Basis(eye, right, up, forward) + } + + /** The camera, turned by [shake] degrees of pitch, yaw and roll when there is a shake to show. */ + fun viewMatrix(shake: FloatArray? = null): Matrix4f { + val basis = cameraBasis() + val look = Matrix4f().setLookAt( + basis.eye.x, basis.eye.y, basis.eye.z, + targetX, targetY, targetZ, + 0f, 1f, 0f, + ) + if (shake == null) return look + return Matrix4f() + .rotateZ(Math.toRadians(shake[2].toDouble()).toFloat()) + .rotateX(Math.toRadians(shake[0].toDouble()).toFloat()) + .rotateY(Math.toRadians(shake[1].toDouble()).toFloat()) + .mul(look) + } + + fun perspective(width: Float, height: Float): Matrix4f = + Matrix4f().setPerspective(FIELD_OF_VIEW, (width / height.coerceAtLeast(1f)).coerceAtLeast(0.01f), NEAR, FAR) + + /** + * Normalized device coordinates to panel pixels. + */ + private fun pixels(rect: UiRect): Matrix4f { + val halfWidth = rect.width / 2f + val halfHeight = rect.height / 2f + return Matrix4f( + halfWidth, 0f, 0f, 0f, + 0f, -halfHeight, 0f, 0f, + 0f, 0f, -PANEL_DEPTH, 0f, + rect.x + halfWidth, rect.y + halfHeight, 0f, 1f, + ) + } + + /** Effect space to panel pixels, before the perspective division. */ + fun panelMatrix(width: Float, height: Float): Matrix4f = + pixels(UiRect(0f, 0f, width, height)).mul(perspective(width, height)).mul(viewMatrix()) + + /** Where [point] lands in the panel, or null when it is behind the camera. */ + fun project(matrix: Matrix4f, point: Vector3f): Vector3f? { + val clip = matrix.transform(Vector4f(point, 1f)) + if (clip.w <= NEAR) return null + return Vector3f(clip.x / clip.w, clip.y / clip.w, clip.w) + } + + private val renderer = VfxPreviewRenderer() + + /** Draws the floor and the effect into [rect]; called from a `drawGl` block. */ + fun render(rect: UiRect, stack: PoseStack) { + val playing = instance ?: return + if (rect.width <= 1f || rect.height <= 1f) return + renderer.render(this, playing, rect, stack) + } + + /** Frees the target the preview draws into. */ + fun close() = renderer.close() + + private companion object { + const val NANOS_PER_SECOND = 1_000_000_000.0 + const val MAX_STEP = 0.25f + + const val PREVIEW_SEED = 0x5EED + + val FIELD_OF_VIEW = Math.toRadians(VfxPreviewFieldOfView.toDouble()).toFloat() + const val NEAR = 0.05f + const val FAR = 1024f + + const val PANEL_DEPTH = 500f + + const val ORBIT_SPEED = 0.35f + const val PAN_SPEED = 0.0025f + const val MIN_DISTANCE = 0.3f + const val MAX_DISTANCE = 60f + const val ZOOM_PER_NOTCH = 1.1f + + const val CATCH_UP_STEP = 1f / 30f + const val CATCH_UP_EPSILON = 1.0e-4f + const val MAX_CATCH_UP_STEPS = 90 + + const val CATCH_UP_LIMIT = 4f + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxSceneTree.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxSceneTree.kt new file mode 100644 index 000000000..e97e97ce0 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxSceneTree.kt @@ -0,0 +1,174 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import net.minecraft.client.Minecraft +import org.lwjgl.glfw.GLFW +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeOverlay +import ru.hollowhorizon.hollowengine.client.ui.ide.SceneTarget +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiKeyInput +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTreeItem +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.vfx.VfxNodeSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxNodeTypes + +/** The id of the row that stands for the effect itself, above its nodes. */ +internal const val VfxRootId = "vfx-root" + +/** + * The node tree as the scene window shows it: the effect as the root, its nodes under it. + */ +internal fun vfxSceneTarget(document: HollowIdeVfxDocument, state: VfxEditorState, title: String) = SceneTarget( + id = "vfx-nodes", + items = buildList { + val nodes = document.effect.nodes + add( + UiTreeItem( + id = VfxRootId, + label = title, + depth = 0, + payload = null, + icon = VfxIcons.EFFECT, + hasChildren = nodes.isNotEmpty(), + expanded = state.rootExpanded, + selected = state.selected == null, + ) + ) + if (state.rootExpanded) appendNodes(nodes, state.expanded, state.selected, depth = 1) + }, + onSelect = { id -> state.select(id.takeUnless { it == VfxRootId }) }, + onToggle = { id -> + when (id) { + VfxRootId -> state.rootExpanded = !state.rootExpanded + in state.expanded -> state.expanded.remove(id) + else -> state.expanded.add(id) + } + }, + hint = vfxText("no_nodes").takeIf { document.effect.nodes.isEmpty() }, + menu = { id -> sceneMenu(document, state, id.takeUnless { it == VfxRootId }) }, + onKey = { input -> handleSceneKey(document, state, input) }, + onMove = { dragged, target -> move(document, state, dragged, target.takeUnless { it == VfxRootId }) }, + canMove = { dragged, target -> target == VfxRootId || !document.effect.isWithin(target, dragged) }, +) + +private fun MutableList>.appendNodes( + nodes: List, + expanded: List, + selected: String?, + depth: Int, +) { + nodes.forEach { node -> + add( + UiTreeItem( + id = node.id, + label = if (node.enabled) node.name else "${node.name} (${vfxText("off")})", + depth = depth, + payload = node, + icon = VfxNodeTypes.of(node)?.icon, + hasChildren = node.children.isNotEmpty(), + expanded = node.id in expanded, + selected = node.id == selected, + ) + ) + if (node.children.isNotEmpty() && node.id in expanded) { + appendNodes(node.children, expanded, selected, depth + 1) + } + } +} + +/** What a right click offers: adding under the node (or the effect), and what can be done to the node. */ +private fun sceneMenu(document: HollowIdeVfxDocument, state: VfxEditorState, id: String?): List { + val add = UiDropdownItem( + vfxText(if (id == null) "add_node" else "add_child"), + icon = VfxIcons.ADD, + children = VfxNodeTypes.all.mapNotNull { type -> + val create = type.createDefault ?: return@mapNotNull null + UiDropdownItem(type.titleKey.lang, icon = type.icon) { add(document, state, id, create()) } + }, + ) + val node = id?.let(document.effect::node) ?: return listOf(add) + + val siblings = document.effect.parentOf(node.id)?.children ?: document.effect.nodes + val index = siblings.indexOfFirst { it.id == node.id } + return listOf( + add, + UiDropdownItem(vfxText("duplicate"), icon = VfxIcons.DUPLICATE, shortcut = "Ctrl+D", separatorBefore = true) { + duplicate(document, state, node.id) + }, + UiDropdownItem(vfxText("rename"), icon = VfxIcons.RENAME, shortcut = "F2") { rename(state, node.id) }, + UiDropdownItem(vfxText(if (node.enabled) "disable" else "enable"), icon = VfxIcons.TOGGLE) { + document.replace(node.withCommon(enabled = !node.enabled)) + }, + UiDropdownItem(vfxText("move_up"), shortcut = "Alt+↑", enabled = index > 0, separatorBefore = true) { + document.edit { it.withShifted(node.id, -1) } + }, + UiDropdownItem(vfxText("move_down"), shortcut = "Alt+↓", enabled = index < siblings.lastIndex) { + document.edit { it.withShifted(node.id, 1) } + }, + UiDropdownItem(vfxText("move_out"), enabled = document.effect.parentOf(node.id) != null) { + val parent = document.effect.parentOf(node.id) ?: return@UiDropdownItem + val grand = document.effect.parentOf(parent.id) + val at = (grand?.children ?: document.effect.nodes).indexOfFirst { it.id == parent.id } + 1 + document.edit { it.withMoved(node.id, grand?.id, at) } + }, + UiDropdownItem(vfxText("remove_node"), icon = VfxIcons.REMOVE, shortcut = "Del", separatorBefore = true) { + remove(document, state, node.id) + }, + ) +} + +private fun handleSceneKey(document: HollowIdeVfxDocument, state: VfxEditorState, input: UiKeyInput): Boolean { + if (input.control && !input.repeat) { + when (input.key) { + GLFW.GLFW_KEY_Z if input.shift -> return document.redo() + GLFW.GLFW_KEY_Z -> return document.undo() + GLFW.GLFW_KEY_Y -> return document.redo() + } + } + val id = state.selected ?: return false + return when { + input.key == GLFW.GLFW_KEY_DELETE -> remove(document, state, id).let { true } + input.key == GLFW.GLFW_KEY_F2 -> rename(state, id).let { true } + input.control && input.key == GLFW.GLFW_KEY_D && !input.repeat -> duplicate(document, state, id).let { true } + input.alt && input.key == GLFW.GLFW_KEY_UP -> document.edit { it.withShifted(id, -1) }.let { true } + input.alt && input.key == GLFW.GLFW_KEY_DOWN -> document.edit { it.withShifted(id, 1) }.let { true } + else -> false + } +} + +private fun add(document: HollowIdeVfxDocument, state: VfxEditorState, parent: String?, node: VfxNodeSpec) { + document.edit { it.withChild(parent, node) } + if (parent == null) state.rootExpanded = true else if (parent !in state.expanded) state.expanded.add(parent) + state.select(node.id) +} + +private fun duplicate(document: HollowIdeVfxDocument, state: VfxEditorState, id: String) { + var created: String? = null + document.edit { effect -> + val (next, copy) = effect.withDuplicate(id) ?: return@edit effect + created = copy + next + } + created?.let(state::select) +} + +private fun remove(document: HollowIdeVfxDocument, state: VfxEditorState, id: String) { + val parent = document.effect.parentOf(id)?.id + document.edit { it.withoutNode(id) } + state.select(parent) +} + +private fun move(document: HollowIdeVfxDocument, state: VfxEditorState, dragged: String, target: String?): Boolean { + if (target != null && document.effect.isWithin(target, dragged)) return false + if (document.effect.parentOf(dragged)?.id == target && target != null) return false + document.edit { it.withMoved(dragged, target) } + if (target == null) state.rootExpanded = true else if (target !in state.expanded) state.expanded.add(target) + state.select(dragged) + return true +} + +/** Selects the node and puts the caret into its name in the inspector. */ +private fun rename(state: VfxEditorState, id: String) { + state.select(id) + Minecraft.getInstance().execute { HollowIdeOverlay.focusSurface("vfx-node-name-$id") } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxShaderInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxShaderInspector.kt new file mode 100644 index 000000000..6a9f5fe3f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxShaderInspector.kt @@ -0,0 +1,194 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Composable +import net.minecraft.client.Minecraft +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraph +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Row +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.UiAlign +import ru.hollowhorizon.hollowengine.client.ui.UiLength +import ru.hollowhorizon.hollowengine.client.ui.alignItems +import ru.hollowhorizon.hollowengine.client.ui.gap +import ru.hollowhorizon.hollowengine.client.ui.grow +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorIconButton +import ru.hollowhorizon.hollowengine.client.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxShaderDeclarations +import ru.hollowhorizon.hollowengine.common.vfx.VfxCameraShakeSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPostEffectSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxSamplerSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxSkySpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformValue + +@Composable +internal fun PostEffectFields(document: HollowIdeVfxDocument, state: VfxEditorState, post: VfxPostEffectSpec) { + Folding(state, "post", vfxText("section_post"), VfxIcons.POST) { + VfxShaderRow(post.shader, vfxText("shader_post_hint"), "vfx-post-shader") { shader -> + document.replace(post.copy(shader = shader.orEmpty(), uniforms = alignUniforms(shader, post.uniforms))) + } + VfxShaderFields( + post.shader, + post.uniforms, + post.samplers, + onUniforms = { document.replace(post.copy(uniforms = it)) }, + onSamplers = { document.replace(post.copy(samplers = it)) }, + ) + } +} + +@Composable +internal fun SkyFields(document: HollowIdeVfxDocument, state: VfxEditorState, sky: VfxSkySpec) { + Folding(state, "sky", vfxText("section_sky"), VfxIcons.SKY) { + VfxShaderRow(sky.shader, vfxText("shader_sky_hint"), "vfx-sky-shader") { shader -> + document.replace(sky.copy(shader = shader.orEmpty(), uniforms = alignUniforms(shader, sky.uniforms))) + } + VfxShaderFields( + sky.shader, + sky.uniforms, + sky.samplers, + onUniforms = { document.replace(sky.copy(uniforms = it)) }, + onSamplers = { document.replace(sky.copy(samplers = it)) }, + ) + } +} + +@Composable +internal fun CameraShakeFields(document: HollowIdeVfxDocument, state: VfxEditorState, shake: VfxCameraShakeSpec) { + Folding(state, "shake", vfxText("section_shake"), VfxIcons.SHAKE) { + VfxValueRow(vfxText("shake_strength"), shake.strength, VfxProperty.SHAKE_STRENGTH, vfxText("shake_strength_hint")) { + document.replace(shake.copy(strength = it)) + } + VfxValueRow(vfxText("shake_frequency"), shake.frequency, VfxProperty.SHAKE_FREQUENCY, vfxText("shake_frequency_hint")) { + document.replace(shake.copy(frequency = it)) + } + VfxFloat3Row(vfxText("shake_amplitude"), shake.amplitude, hint = vfxText("shake_amplitude_hint")) { + document.replace(shake.copy(amplitude = it)) + } + } +} + +/** + * The shader a surface or a screen effect is drawn with, by its `namespace:path` name. [hint] says what + * the geometry hands the shader, so it lives in the tooltip of the label. + */ +@Composable +internal fun VfxShaderRow( + shader: String?, + hint: String, + id: String, + materials: Boolean = false, + onChange: (String?) -> Unit, +) { + VfxAssetRow( + label = vfxText(if (materials) "surface_shader" else "shader"), + value = shader.orEmpty(), + hint = hint, + id = id, + candidates = { VfxShaderAssets.list(materials) }, + exists = VfxShaderAssets::exists, + placeholder = vfxText("shader_placeholder"), + ) { typed -> onChange(typed.trim().ifBlank { null }) } +} + +/** + * What the loaded packs offer an effect to draw with, named the way a material names them. + */ +internal object VfxShaderAssets { + private const val Root = "shaders/core/" + private const val Materials = "materials" + + /** The core shaders, and before them with [materials] the material graphs, which only surfaces draw with. */ + fun list(materials: Boolean): List { + val manager = Minecraft.getInstance().resourceManager ?: return emptyList() + return runCatching { + val graphs = if (materials) { + manager.listResources(Materials) { it.path.endsWith(ShaderGraph.EXTENSION) }.keys.map { it.toString() }.sorted() + } else { + emptyList() + } + val shaders = manager.listResources(Root.trimEnd('/')) { it.path.endsWith(".json") }.keys + .filter { it.namespace != "minecraft" } + .map { "${it.namespace}:${it.path.removePrefix(Root).removeSuffix(".json")}" } + .sorted() + graphs + shaders + }.getOrDefault(emptyList()) + } + + fun exists(name: String): Boolean = VfxShaderDeclarations.of(name) != null +} + +/** [authored] brought in line with what [shader] declares, or left as it is when it cannot be read. */ +internal fun alignUniforms(shader: String?, authored: List): List = + shader?.let(VfxShaderDeclarations::of)?.align(authored) ?: authored + +/** + * One row per uniform and sampler the shader json declares; the engine's own are left out. A shader + * that cannot be read shows nothing here, its name field already says why. + */ +@Composable +internal fun VfxShaderFields( + shader: String?, + uniforms: List, + samplers: List, + onUniforms: (List) -> Unit, + onSamplers: (List) -> Unit, +) { + if (shader.isNullOrBlank()) return + val declaration = VfxShaderDeclarations.of(shader) ?: return + + declaration.uniforms.forEach { uniform -> + val value = uniforms.firstOrNull { it.name == uniform.name && uniform.accepts(it.value) }?.value + ?: uniform.defaultValue() + val property = VfxProperty.uniform(uniform.name) + fun set(next: VfxUniformValue) = onUniforms(uniforms.upsert(VfxUniformSpec(uniform.name, next)) { it.name }) + + when (value) { + is VfxUniformValue.Scalar -> VfxValueRow(uniform.name, value.value, property) { + set(VfxUniformValue.Scalar(it)) + } + + is VfxUniformValue.Vector -> VfxVec3Row(uniform.name, value.value, property) { + set(VfxUniformValue.Vector(it)) + } + + is VfxUniformValue.Color -> VfxColorRow(uniform.name, value.value, property) { + set(VfxUniformValue.Color(it)) + } + } + } + + val stale = uniforms.filter { declaration.uniform(it.name) == null } + if (stale.isNotEmpty()) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + Text( + vfxText("uniforms_stale") + " " + stale.joinToString { it.name }, + tags = listOf("insp-hint"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f), + ) + InspectorIconButton( + VfxIcons.REMOVE, + vfxText("uniforms_drop"), + tags = listOf("insp-inline-icon", "danger"), + ) { onUniforms(uniforms - stale.toSet()) } + } + } + + declaration.samplers.forEach { name -> + val texture = samplers.firstOrNull { it.name == name }?.texture.orEmpty() + VfxAssetRow(name, texture, VfxTextureExtensions, id = "vfx-sampler-$name") { typed -> + val kept = samplers.filterNot { it.name == name } + onSamplers(if (typed.isBlank()) kept else samplers.upsert(VfxSamplerSpec(name, typed)) { it.name }) + } + } +} + +/** [item] in place of the element with the same key, or added at the end when there is none. */ +private fun List.upsert(item: T, key: (T) -> String): List { + val index = indexOfFirst { key(it) == key(item) } + return if (index < 0) this + item else toMutableList().also { it[index] = item } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxSurfaceInspector.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxSurfaceInspector.kt new file mode 100644 index 000000000..442a730a1 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxSurfaceInspector.kt @@ -0,0 +1,332 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.inspector.AssetPathField +import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorHost +import ru.hollowhorizon.hollowengine.client.ui.inspector.Pill +import ru.hollowhorizon.hollowengine.client.ui.inspector.PillFlow +import ru.hollowhorizon.hollowengine.client.ui.inspector.Pills +import ru.hollowhorizon.hollowengine.client.ui.inspector.ToggleRow +import ru.hollowhorizon.hollowengine.client.ui.inspector.resourceExists +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint +import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover +import ru.hollowhorizon.hollowengine.common.vfx.* + +internal val VfxModelExtensions = listOf(".glb", ".gltf", ".fbx", ".obj") +internal val VfxTextureExtensions = listOf(".png") + +/** A path to a file of one of [extensions], offered from the loaded packs. */ +@Composable +internal fun VfxAssetRow( + label: String, + value: String, + extensions: List, + hint: String? = null, + id: String = "vfx-asset-$label", + onChange: (String) -> Unit, +) = VfxAssetRow(label, value, hint, id, candidates = { host -> host.assets(extensions) }, onChange = onChange) + +/** + * Something named by a path: typed with completions, underlined when the packs have nothing by that + * name, and picked from a tree with the button beside it. + */ +@Composable +internal fun VfxAssetRow( + label: String, + value: String, + hint: String? = null, + id: String = "vfx-asset-$label", + candidates: (InspectorHost) -> List, + exists: (String) -> Boolean = ::resourceExists, + placeholder: String = "", + onChange: (String) -> Unit, +) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(label, hint) + Box(modifier = Modifier.size(0.px, UiLength.Fit).grow(1f)) { + AssetPathField( + value = value, + label = label, + id = id, + candidates = candidates, + exists = exists, + placeholder = placeholder, + onChange = onChange, + ) + } + } +} + +@Composable +internal fun PlaneFields(document: HollowIdeVfxDocument, state: VfxEditorState, plane: VfxPlaneSpec) { + Folding(state, "facing", vfxText("section_facing"), VfxIcons.FACING) { + Pills(VfxFacing.entries, plane.facing, { vfxText("facing_${it.name.lowercase()}") }) { + document.replace(plane.copy(facing = it)) + } + if (plane.facing == VfxFacing.CAMERA_AXIS) { + VfxFloat3Row(vfxText("facing_axis"), plane.facingAxis, hint = vfxText("facing_axis_hint")) { + document.replace(plane.copy(facingAxis = it)) + } + } + } + MaterialFields(document, state, plane) +} + +@Composable +internal fun MeshFields(document: HollowIdeVfxDocument, state: VfxEditorState, mesh: VfxMeshSpec) { + Folding(state, "mesh", vfxText("section_mesh"), VfxIcons.MESH) { + ToggleRow(vfxText("align_to_velocity"), mesh.alignToVelocity) { + document.replace( + when (mesh) { + is VfxCubeSpec -> mesh.copy(alignToVelocity = it) + is VfxSphereSpec -> mesh.copy(alignToVelocity = it) + is VfxCylinderSpec -> mesh.copy(alignToVelocity = it) + else -> mesh + } + ) + } + when (mesh) { + is VfxSphereSpec -> VfxIntRow(vfxText("segments"), mesh.segments, min = 4, max = 128) { + document.replace(mesh.copy(segments = it)) + } + + is VfxCylinderSpec -> { + VfxIntRow(vfxText("segments"), mesh.segments, min = 3, max = 128) { + document.replace(mesh.copy(segments = it)) + } + ToggleRow(vfxText("caps"), mesh.caps) { document.replace(mesh.copy(caps = it)) } + } + + else -> Unit + } + } + MaterialFields(document, state, mesh) +} + +@Composable +internal fun ModelFields(document: HollowIdeVfxDocument, state: VfxEditorState, model: VfxModelSpec) { + Folding(state, "model", vfxText("section_model"), VfxIcons.MODEL) { + VfxAssetRow(vfxText("model"), model.model, VfxModelExtensions, id = "vfx-model-path") { + document.replace(model.copy(model = it)) + } + ToggleRow(vfxText("align_to_velocity"), model.alignToVelocity) { + document.replace(model.copy(alignToVelocity = it)) + } + ToggleRow(vfxText("emissive"), model.emissive) { document.replace(model.copy(emissive = it)) } + } +} + +@Composable +internal fun TrailFields(document: HollowIdeVfxDocument, state: VfxEditorState, trail: VfxTrailSpec) { + Folding(state, "trail", vfxText("section_trail"), VfxIcons.TRAIL) { + VfxValueRow(vfxText("width"), trail.width, VfxProperty.WIDTH, vfxText("ribbon_width_hint")) { + document.replace(trail.copy(width = it)) + } + VfxNumberRow(vfxText("trail_lifetime"), trail.lifetime, vfxText("trail_lifetime_hint"), min = 0.01f) { + document.replace(trail.copy(lifetime = it)) + } + VfxNumberRow(vfxText("trail_min_distance"), trail.minDistance, vfxText("trail_min_distance_hint"), min = 0f) { + document.replace(trail.copy(minDistance = it)) + } + VfxIntRow(vfxText("trail_max_points"), trail.maxPoints, min = 2, max = 256) { + document.replace(trail.copy(maxPoints = it)) + } + RibbonUvRows(trail.uvMode, trail.tileLength, { document.replace(trail.copy(uvMode = it)) }) { + document.replace(trail.copy(tileLength = it)) + } + } + MaterialFields(document, state, trail) +} + +@Composable +internal fun BeamFields(document: HollowIdeVfxDocument, state: VfxEditorState, beam: VfxBeamSpec) { + Folding(state, "beam", vfxText("section_beam"), VfxIcons.BEAM) { + val nodes = document.effect.walk().map { it.id }.filter { it != beam.id } + VfxAssetRow( + label = vfxText("beam_target"), + value = beam.target, + hint = vfxText("beam_target_hint"), + id = "vfx-beam-target", + candidates = { nodes }, + exists = { it in nodes }, + placeholder = vfxText("beam_target_none"), + ) { document.replace(beam.copy(target = it.trim())) } + if (beam.target.isBlank()) { + VfxFloat3Row(vfxText("beam_end"), beam.end, VfxProperty.BEAM_END, vfxText("beam_end_hint")) { + document.replace(beam.copy(end = it)) + } + } + VfxValueRow(vfxText("width"), beam.width, VfxProperty.WIDTH, vfxText("ribbon_width_hint")) { + document.replace(beam.copy(width = it)) + } + VfxIntRow(vfxText("segments"), beam.segments, min = 1, max = 256) { document.replace(beam.copy(segments = it)) } + VfxValueRow(vfxText("beam_noise"), beam.noise, VfxProperty.BEAM_NOISE, vfxText("beam_noise_hint")) { + document.replace(beam.copy(noise = it)) + } + VfxNumberRow(vfxText("frequency"), beam.noiseFrequency, vfxText("beam_frequency_hint"), min = 0f) { + document.replace(beam.copy(noiseFrequency = it)) + } + VfxNumberRow(vfxText("beam_noise_speed"), beam.noiseSpeed, min = 0f) { document.replace(beam.copy(noiseSpeed = it)) } + VfxNumberRow(vfxText("uv_scroll"), beam.uvScroll, vfxText("uv_scroll_hint")) { document.replace(beam.copy(uvScroll = it)) } + RibbonUvRows(beam.uvMode, beam.tileLength, { document.replace(beam.copy(uvMode = it)) }) { + document.replace(beam.copy(tileLength = it)) + } + } + MaterialFields(document, state, beam) +} + +@Composable +private fun RibbonUvRows(mode: VfxRibbonUv, tileLength: Float, onMode: (VfxRibbonUv) -> Unit, onTile: (Float) -> Unit) { + Pills(VfxRibbonUv.entries, mode, { vfxText("ribbon_uv_${it.name.lowercase()}") }, onMode) + if (mode == VfxRibbonUv.TILE) { + VfxNumberRow(vfxText("tile_length"), tileLength, vfxText("tile_length_hint"), min = 0.01f, onChange = onTile) + } +} + +/** + * Everything a surface is drawn with: the look of it first, then how it blends and meets the depth + * buffer, then the shader of the author's with what that shader declares. + */ +@Composable +private fun MaterialFields(document: HollowIdeVfxDocument, state: VfxEditorState, surface: VfxSurfaceSpec) { + val material = surface.material + fun update(next: VfxMaterialSpec) = document.replace(surface.withSurface(material = next)) + + Folding(state, "material", vfxText("section_material"), VfxIcons.MATERIAL) { + VfxMaterialPreview(state, surface) + + VfxAssetRow(vfxText("texture"), material.texture, VfxTextureExtensions, id = "vfx-material-texture") { + update(material.copy(texture = it)) + } + VfxColorRow(vfxText("tint"), surface.tint, VfxProperty.TINT, vfxText("tint_hint")) { + document.replace(surface.withSurface(tint = it)) + } + BlendPicker(material.blend) { update(material.copy(blend = it)) } + VfxNumberRow(vfxText("glow"), material.glow, vfxText("glow_hint"), min = 0f, max = 64f) { + update(material.copy(glow = it)) + } + VfxNumberRow(vfxText("softness"), material.softness, vfxText("softness_hint"), min = 0f, max = 16f) { + update(material.copy(softness = it)) + } + UvRows(material.uv) { update(material.copy(uv = it)) } + + PillFlow(id = "vfx-material-switches") { + Pill(vfxText("lit_by_world"), material.lighting == VfxLighting.WORLD, id = "vfx-material-lit") { + val lit = material.lighting != VfxLighting.WORLD + update(material.copy(lighting = if (lit) VfxLighting.WORLD else VfxLighting.UNLIT)) + } + Pill(vfxText("depth_test"), material.depthTest, id = "vfx-material-depth-test") { + update(material.copy(depthTest = !material.depthTest)) + } + Pill(vfxText("depth_write"), material.depthWrite, id = "vfx-material-depth-write") { + update(material.copy(depthWrite = !material.depthWrite)) + } + Pill(vfxText("cull"), material.cull, id = "vfx-material-cull") { + update(material.copy(cull = !material.cull)) + } + } + + val attributes = when (surface) { + is VfxMeshSpec -> "shader_mesh_hint" + is VfxPlaneSpec -> "shader_plane_hint" + else -> "shader_ribbon_hint" + } + VfxShaderRow(material.shader, vfxText(attributes), "vfx-material-shader", materials = true) { shader -> + update(material.copy(shader = shader, uniforms = alignUniforms(shader, material.uniforms))) + } + VfxShaderFields( + material.shader, + material.uniforms, + material.samplers, + onUniforms = { update(material.copy(uniforms = it)) }, + onSamplers = { update(material.copy(samplers = it)) }, + ) + } +} + +@Composable +private fun UvRows(uv: VfxUvRect, onChange: (VfxUvRect) -> Unit) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(vfxText("uv_region"), vfxText("uv_hint")) + UvNumber("U", uv.u0) { onChange(uv.copy(u0 = it)) } + UvNumber("V", uv.v0) { onChange(uv.copy(v0 = it)) } + UvNumber("U", uv.u1) { onChange(uv.copy(u1 = it)) } + UvNumber("V", uv.v1) { onChange(uv.copy(v1 = it)) } + } +} + +@Composable +private fun UvNumber(axis: String, value: Float, onChange: (Float) -> Unit) { + Row(modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).gap(2.px).alignItems(vertical = UiAlign.CENTER)) { + Text(axis, modifier = Modifier.fontSize(8f).foreground(MutedText)) + VfxNumberCellInline(value) { onChange(it.coerceIn(0f, 1f)) } + } +} + +@Composable +private fun BlendPicker(current: VfxBlend, onChange: (VfxBlend) -> Unit) { + Row(modifier = Modifier.size(100.percent).gap(3.px).alignItems(vertical = UiAlign.CENTER)) { + VfxFieldLabel(vfxText("blend")) + VfxBlend.entries.forEach { blend -> + val selected = blend == current + Box( + id = "vfx-blend-${blend.name.lowercase()}", + modifier = Modifier.size(0.px, 16.px).grow(1f).input(hoverable = true, clickable = true) + .cursor(UiCursorShape.HAND) + .tooltipOnHover(vfxText("blend_${blend.name.lowercase()}") + ": " + vfxText("blend_${blend.name.lowercase()}_hint")) + .onClick { event -> + onChange(blend) + event.consume() + }.drawBehind(key = blend to selected) { + drawBlendPreview(blend, size.width, size.height, selected) + }, + ) + } + } +} + +private fun UiCanvasDrawScope.drawBlendPreview(blend: VfxBlend, width: Float, height: Float, selected: Boolean) { + val half = width / 2f + drawRect(UiRect(0f, 0f, width, height), UiPaint.Color(DarkGround), radius = 3f) + drawRect(UiRect(half, 0f, width - half, height), UiPaint.Color(LightGround)) + + val inset = height * 0.22f + val left = UiRect(width * 0.2f, inset, half - width * 0.2f, height - inset * 2f) + val right = UiRect(half, inset, half - width * 0.2f, height - inset * 2f) + drawRect(left, UiPaint.Color(blended(blend, DarkGround))) + drawRect(right, UiPaint.Color(blended(blend, LightGround))) + + val border = if (selected) SelectedBorder else IdleBorder + val thickness = if (selected) 1.5f else 1f + drawRect(UiRect(0f, 0f, width, thickness), UiPaint.Color(border)) + drawRect(UiRect(0f, height - thickness, width, thickness), UiPaint.Color(border)) + drawRect(UiRect(0f, 0f, thickness, height), UiPaint.Color(border)) + drawRect(UiRect(width - thickness, 0f, thickness, height), UiPaint.Color(border)) +} + +/** What the sample particle leaves on [ground], worked out the way the blend state would. */ +private fun blended(blend: VfxBlend, ground: UiColor): UiColor { + val alpha = SampleParticle.alpha + fun channel(source: Float, destination: Float): Float = when (blend) { + VfxBlend.OPAQUE -> source + VfxBlend.BLEND -> source * alpha + destination * (1f - alpha) + VfxBlend.ADDITIVE -> (destination + source * alpha).coerceAtMost(1f) + VfxBlend.MULTIPLY -> source * destination + destination * (1f - alpha) + } + return UiColor( + channel(SampleParticle.red, ground.red), + channel(SampleParticle.green, ground.green), + channel(SampleParticle.blue, ground.blue), + 1f, + ) +} + +private val SampleParticle = UiColor(1f, 0.55f, 0.15f, 0.75f) +private val DarkGround = UiColor(0.12f, 0.13f, 0.15f, 1f) +private val LightGround = UiColor(0.78f, 0.8f, 0.84f, 1f) +private val SelectedBorder = UiColor(0.43f, 0.61f, 0.86f, 1f) +private val IdleBorder = UiColor(0.17f, 0.18f, 0.21f, 1f) +private val MutedText = UiColor(0.55f, 0.58f, 0.65f, 1f) 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 new file mode 100644 index 000000000..53281822c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTimelineBridge.kt @@ -0,0 +1,267 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeVfxDocument +import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* +import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.utils.Color +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.* + +/** + * Puts the effect timeline into the editor timeline and takes it back out. + */ +class VfxTimelineSession( + private val document: HollowIdeVfxDocument, + private val preview: VfxPreviewState, +) { + val timeline = TimelineController() + + /** Properties the author asked for, as `node|property`. */ + private val pinned = LinkedHashSet() + + /** Properties the author took off the list; they stay off even with keys. */ + private val hidden = HashSet() + + /** Nodes that were selected at some point, so their group row stays. */ + private val touched = LinkedHashSet() + + private var suppressCommit = false + private var builtFor: Int = -1 + private var signature: List = emptyList() + + init { + timeline.onChanged = { + commit() + applyListing() + } + timeline.onTimeChanged = { preview.requestSeek(timeline.currentTime) } + timeline.onHideProperty = { property -> hide(property.id) } + timeline.onHideGroup = { group -> hideNode(group) } + } + + /** Rebuilds the tracks when the node tree, or the properties it offers, changed under the timeline. */ + fun sync() { + if (builtFor == document.revision) return + builtFor = document.revision + + val next = signatureOf() + if (next == signature && timeline.groups.isNotEmpty()) return + load() + } + + /** Resets the editor state and builds it again from the file. */ + fun load() { + suppressCommit = true + timeline.groups.clear() + + val effect = document.effect + timeline.workAreaEnd = effect.timeline.duration.coerceAtLeast(0.1f) + + val names = effect.walk().groupingBy { it.name }.eachCount() + effect.walk().forEach { node -> + val label = if ((names[node.name] ?: 0) > 1) "${node.name} (${node.id})" else node.name + VfxAnimatables.forNode(node).forEach { animatable -> + val property = timeline.addProperty(listOf(label), buildProperty(node, animatable)) + fill(property, effect.timeline.track(node.id, animatable.property)) + } + } + + signature = signatureOf() + builtFor = document.revision + suppressCommit = false + applyListing() + } + + /** Lists the property of [nodeId] the author is editing, so its keys are one glance away. */ + fun focus(nodeId: String, property: VfxProperty) { + val id = propertyId(nodeId, property) + touched += nodeId + pinned += id + hidden -= id + applyListing() + } + + /** Keeps the row of [nodeId] once it has been selected. */ + fun touch(nodeId: String) { + if (touched.add(nodeId)) applyListing() + } + + private fun hide(id: String) { + pinned -= id + hidden += id + applyListing() + } + + private fun hideNode(group: TrackGroup) { + val properties = group.allProperties() + val node = properties.firstOrNull()?.let(::nodeOf) ?: return + touched -= node + properties.forEach { property -> + pinned -= property.id + hidden += property.id + } + applyListing() + } + + /** Decides which rows track list shows. */ + fun applyListing() { + timeline.groups.forEach { group -> applyListing(group) } + } + + private fun applyListing(group: TrackGroup) { + group.children.forEach(::applyListing) + group.properties.forEach { property -> + val keyed = property.curves.any { it.keyframes.isNotEmpty() } + property.isListed = property.id !in hidden && (keyed || property.id in pinned) + } + val node = group.properties.firstOrNull()?.let(::nodeOf) + group.isListed = node != null && node in touched + } + + /** Writes whatever the editor holds back into the file. */ + fun commit() { + if (suppressCommit) return + + val tracks = timeline.allProperties().mapNotNull { property -> + val node = nodeOf(property) ?: return@mapNotNull null + val animatable = animatableOf(property) ?: return@mapNotNull null + val curves = property.curves.mapIndexedNotNull { channel, curve -> + if (curve.keyframes.isEmpty()) null + else VfxTrackCurve(channel, curve.isVisible, curve.keyframes.map { key -> key.toStored(curve) }) + } + if (curves.isEmpty()) null else VfxTrack(node, animatable, curves) + } + + val spec = VfxTimelineSpec( + duration = timeline.workAreaEnd, + loop = document.effect.timeline.loop, + tracks = tracks, + ) + if (spec == document.effect.timeline) return + + suppressCommit = true + document.edit(history = false) { it.copy(timeline = spec) } + builtFor = document.revision + suppressCommit = false + } + + /** What the timeline rows depend on, which nodes there are, what they are called, what they offer. */ + private fun signatureOf(): List = document.effect.walk().map { node -> + Triple(node.id, node.name, VfxAnimatables.forNode(node).map { it.property }) + } + + private fun buildProperty(node: VfxNodeSpec, animatable: VfxAnimatable): AnimProperty<*> { + val id = propertyId(node.id, animatable.property) + val title = animatable.titleKey.lang + val name = animatable.ownerTitleKey?.let { "${it.lang} · $title" } ?: title + val defaults = animatable.read() + + return when (animatable.kind) { + VfxAnimatableKind.TOGGLE -> AnimProperty(id, name, TogglePropertyType, defaults.getOrElse(0) { 1f }) + VfxAnimatableKind.FLOAT -> AnimProperty(id, name, FloatPropertyType(name), defaults.getOrElse(0) { 0f }) + VfxAnimatableKind.VEC3 -> AnimProperty(id, name, TranslationPropertyType(), vector(defaults)) + VfxAnimatableKind.ROTATION -> AnimProperty( + id, + name, + RotationPropertyType(RotationMode.EULER), + vector(defaults), + ) + + VfxAnimatableKind.COLOR -> AnimProperty(id, name, ColorChannelsPropertyType, defaults.copyOf(4)) + } + } + + private fun vector(values: FloatArray) = + Vec3f(values.getOrElse(0) { 0f }, values.getOrElse(1) { 0f }, values.getOrElse(2) { 0f }) + + private fun fill(property: AnimProperty<*>, track: VfxTrack?) { + if (track == null) return + + track.curves.forEach { stored -> + val curve = property.curves.getOrNull(stored.channel) ?: return@forEach + curve.isVisible = stored.visible + stored.keys.forEach { key -> curve.keyframes.add(key.toEditor()) } + curve.sort() + } + } + + private fun propertyId(node: String, property: VfxProperty) = "$node|$property" + + /** The node a timeline row belongs to, as packed into its id. */ + private fun nodeOf(property: AnimProperty<*>): String? = property.id.split('|', limit = 2).getOrNull(0) + + private fun animatableOf(property: AnimProperty<*>): VfxProperty? = + property.id.split('|', limit = 2).getOrNull(1)?.let(::VfxProperty) +} + +/** On or off, held between keys; the editor draws it as a step curve and never interpolates it. */ +private object TogglePropertyType : PropertyType { + override val id = "vfx_toggle" + + override val channels = listOf( + ChannelSpec( + name = "On", + color = ChannelColors.SCALAR, + sampling = ChannelSampling.DISCRETE, + valueOptions = listOf( + ChannelValueOption(0f, "hollowengine.gui.vfx.off"), + ChannelValueOption(1f, "hollowengine.gui.vfx.on"), + ), + ), + ) + + override fun decompose(value: Float, into: FloatArray) { + into[0] = value + } + + override fun compose(values: FloatArray): Float = if (values[0] >= 0.5f) 1f else 0f +} + +/** Red, green, blue and alpha as four curves, the way a color is keyed. */ +private object ColorChannelsPropertyType : PropertyType { + override val id = "vfx_color" + + override val channels = listOf( + ChannelSpec(name = "R", color = Color("E06C75")), + ChannelSpec(name = "G", color = Color("98C379")), + ChannelSpec(name = "B", color = Color("61AFEF")), + ChannelSpec(name = "A", color = Color("ABB2BF")), + ) + + override fun decompose(value: FloatArray, into: FloatArray) { + for (channel in 0 until 4) into[channel] = value.getOrElse(channel) { 1f } + } + + override fun compose(values: FloatArray): FloatArray = values.copyOf(4) +} + +internal fun VfxKey.toEditor(): Keyframe = Keyframe( + time = time, + value = value, + interpolation = when (interpolation) { + VfxInterpolation.CONSTANT -> KeyInterpolation.CONSTANT + VfxInterpolation.LINEAR -> KeyInterpolation.LINEAR + VfxInterpolation.BEZIER -> KeyInterpolation.BEZIER + }, + handleMode = HandleMode.FREE, + incoming = KeyTangent(inTime, inValue), + outgoing = KeyTangent(outTime, outValue), +) + +/** A key as the file stores it. */ +internal fun Keyframe.toStored(curve: ChannelCurve): VfxKey { + val tangents = curve.effectiveTangents(this) + return VfxKey( + time = time, + value = value, + interpolation = when (interpolation) { + KeyInterpolation.CONSTANT -> VfxInterpolation.CONSTANT + KeyInterpolation.LINEAR -> VfxInterpolation.LINEAR + KeyInterpolation.BEZIER -> VfxInterpolation.BEZIER + }, + inTime = tangents.incoming.time, + inValue = tangents.incoming.value, + outTime = tangents.outgoing.time, + outValue = tangents.outgoing.value, + ) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTransformGizmo.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTransformGizmo.kt new file mode 100644 index 000000000..a287a5c15 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/vfx/VfxTransformGizmo.kt @@ -0,0 +1,130 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.files.vfx + +import net.minecraft.world.phys.Vec3 +import org.joml.Quaternionf +import org.joml.Vector3f +import ru.hollowhorizon.hollowengine.client.editor.GizmoDrag +import ru.hollowhorizon.hollowengine.client.editor.GizmoEditMode +import ru.hollowhorizon.hollowengine.client.editor.GizmoGeometry +import ru.hollowhorizon.hollowengine.client.editor.GizmoHandle +import ru.hollowhorizon.hollowengine.client.editor.GizmoHandleId +import ru.hollowhorizon.hollowengine.client.editor.GizmoManipulator +import ru.hollowhorizon.hollowengine.client.editor.GizmoRenderer +import ru.hollowhorizon.hollowengine.client.editor.GizmoPicker +import ru.hollowhorizon.hollowengine.client.editor.GizmoProjector +import ru.hollowhorizon.hollowengine.client.editor.GizmoTransformValues +import ru.hollowhorizon.hollowengine.client.editor.isRotation +import ru.hollowhorizon.hollowengine.client.ui.UiCanvasDrawScope +import ru.hollowhorizon.hollowengine.client.utils.math.conjugate +import ru.hollowhorizon.hollowengine.client.utils.math.rotateBy +import ru.hollowhorizon.hollowengine.client.vfx.VfxFrame +import ru.hollowhorizon.hollowengine.common.utils.math.QuatF +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxTransform + +/** + * The transform gizmo of the world, over the effect preview: the same handles, picking and drag math, + * projected from the preview camera into the pixels of the panel. + */ +internal class VfxTransformGizmo { + private val projector = GizmoProjector() + private val geometry = GizmoGeometry(projector) + private val manipulator = GizmoManipulator(projector) + + /** Takes the preview camera; the handles built after this are in panel pixels. */ + fun capture(preview: VfxPreviewState) { + val width = preview.viewportWidth + val height = preview.viewportHeight + val eye = preview.cameraBasis().eye + projector.capture( + view = preview.viewMatrix().setTranslation(0f, 0f, 0f), + projection = preview.perspective(width, height), + cameraPosition = Vec3(eye.x.toDouble(), eye.y.toDouble(), eye.z.toDouble()), + fovDegrees = VfxPreviewFieldOfView, + width = width, + height = height, + ) + } + + /** + * The handles for [frame], the node as it is placed right now. A part of the placement that the + * timeline drives offers no handles: the timeline would put it back on the next frame. + */ + fun handles(frame: VfxFrame, modes: Set, driven: (VfxProperty) -> VfxDrivenValue?): List { + val editable = modes.filterTo(HashSet()) { mode -> driven(mode.property) == null } + if (editable.isEmpty()) return emptyList() + return geometry.buildHandles(frame.position.copy(), frame.rotation.copy(), editable) + } + + fun pick(handles: List, x: Float, y: Float): GizmoHandle? = GizmoPicker.pick(handles, x, y) + + fun begin(handle: GizmoHandle, frame: VfxFrame, parent: VfxFrame?, start: VfxTransform, x: Float, y: Float): VfxTransformDrag { + val values = GizmoTransformValues(frame.position.copy(), frame.rotation.copy(), start.scale) + val gizmo = manipulator.begin(handle, values, x, y) + return VfxTransformDrag(gizmo, start, parent?.let { VfxFrame().set(it) } ?: VfxFrame()) + } + + /** The placement [drag] has come to with the pointer at ([x], [y]), or null while nothing moved. */ + fun drag(drag: VfxTransformDrag, x: Float, y: Float, modifiers: Int): VfxTransform? { + val values = manipulator.update(drag.gizmo, x, y, modifiers) ?: return null + val start = drag.start + val parent = drag.parent + return when (drag.gizmo.handleId.mode) { + GizmoEditMode.TRANSLATE -> start.copy(position = parent.toLocalPoint(values.translation)) + GizmoEditMode.ROTATE -> start.copy(rotation = eulerDegrees(parent.rotation.conjugate() * values.rotation)) + GizmoEditMode.SCALE -> start.copy(scale = values.scale) + } + } + + fun draw(scope: UiCanvasDrawScope, handles: List, hovered: GizmoHandleId?, drag: VfxTransformDrag?) { + drag?.gizmo?.let { GizmoRenderer.drawRotationSector(scope, geometry, projector, it) } + GizmoRenderer.drawHandles(scope, handles, hovered, drag?.gizmo?.handleId) + } + + /** Where the value label of [drag] goes: next to the origin of the node, in panel pixels. */ + fun labelAt(drag: VfxTransformDrag): Pair? { + val screen = projector.project(drag.gizmo.origin) ?: return null + return if (screen.onScreen) screen.x to screen.y else null + } + + private fun VfxFrame.toLocalPoint(world: Vec3f): Vec3f { + val relative = Vec3f(world.x - position.x, world.y - position.y, world.z - position.z) + .rotateBy(rotation.conjugate()) + return Vec3f(relative.x / safe(scale.x), relative.y / safe(scale.y), relative.z / safe(scale.z)) + } + + private fun safe(value: Float): Float = if (value in -1.0e-6f..1.0e-6f) 1f else value + + /** Euler degrees in the order [VfxFrame.eulerOf] applies them: X, then Y, then Z. */ + private fun eulerDegrees(rotation: QuatF): Vec3f { + val angles = Quaternionf(rotation.x, rotation.y, rotation.z, rotation.w).getEulerAnglesXYZ(Vector3f()) + return Vec3f(Math.toDegrees(angles.x.toDouble()).toFloat(), Math.toDegrees(angles.y.toDouble()).toFloat(), + Math.toDegrees(angles.z.toDouble()).toFloat()) + } + + private fun Vec3f.copy(): Vec3f = Vec3f(x, y, z) + + private fun QuatF.copy(): QuatF = QuatF(x, y, z, w) +} + +/** A gizmo drag on a node: the drag of the gizmo, and the node's placement and parent when it began. */ +internal class VfxTransformDrag(val gizmo: GizmoDrag, val start: VfxTransform, val parent: VfxFrame) { + /** What the value label shows: a distance, an angle in degrees or a scale factor. */ + val label: Float get() = gizmo.labelValue.toFloat() +} + +private val GizmoEditMode.property: VfxProperty + get() = when (this) { + GizmoEditMode.TRANSLATE -> VfxProperty.POSITION + GizmoEditMode.ROTATE -> VfxProperty.ROTATION + GizmoEditMode.SCALE -> VfxProperty.SCALE + } + +private val GizmoHandleId.mode: GizmoEditMode + get() = when (this) { + GizmoHandleId.SCALE_X, GizmoHandleId.SCALE_Y, GizmoHandleId.SCALE_Z, GizmoHandleId.SCALE_UNIFORM -> + GizmoEditMode.SCALE + + else -> if (isRotation()) GizmoEditMode.ROTATE else GizmoEditMode.TRANSLATE + } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleCommandAssist.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleCommandAssist.kt index c25eb45a0..cdb2d85f6 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleCommandAssist.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleCommandAssist.kt @@ -26,11 +26,11 @@ internal class ConsoleCommandAssist(private val onUpdated: () -> Unit) { private var diagnostics = DiagnosticSnapshot("", emptyList()) @Volatile - private var colours = HighlightSnapshot("", emptyList()) + private var colors = HighlightSnapshot("", emptyList()) /** Colors the parsed parts of the command; anything not parsed yet simply stays plain. */ val highlighter: UiSyntaxHighlighter = UiSyntaxHighlighter { text -> - colours.takeIf { it.text == text }?.items.orEmpty() + colors.takeIf { it.text == text }?.items.orEmpty() } val contributor: UiCompletionContributor = object : UiCompletionContributor { @@ -97,7 +97,7 @@ internal class ConsoleCommandAssist(private val onUpdated: () -> Unit) { private fun publishParse(text: String, problems: List, highlights: List) { diagnostics = DiagnosticSnapshot(text, problems) - colours = HighlightSnapshot(text, highlights) + colors = HighlightSnapshot(text, highlights) onUpdated() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleHistory.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleHistory.kt new file mode 100644 index 000000000..91d0251e6 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleHistory.kt @@ -0,0 +1,106 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.panels + +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import net.minecraft.client.Minecraft +import org.apache.logging.log4j.LogManager +import ru.hollowhorizon.hollowengine.common.config.Config +import kotlin.io.path.createParentDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** Where earlier inputs of one console mode live, oldest first. */ +internal interface ConsoleHistoryStore { + val entries: List + + fun add(entry: String) +} + +/** + * The chat's own history, so a command sent from the console comes up on Up in the chat and the + * other way round. + */ +internal object VanillaCommandHistory : ConsoleHistoryStore { + override val entries: List + get() = Minecraft.getInstance().gui.chat.recentChat + .filter { it.startsWith("/") } + .map { it.removePrefix("/") } + + override fun add(entry: String) { + Minecraft.getInstance().gui.chat.addRecentChat("/" + entry.removePrefix("/")) + } +} + +/** Kotlin snippets, kept in the config directory between sessions. */ +internal object ConsoleSnippetHistory : ConsoleHistoryStore { + private const val MaxEntries = 50 + + private val LOGGER = LogManager.getLogger("HollowIde") + private val serializer = ListSerializer(String.serializer()) + private val json = Json { prettyPrint = true } + private val file get() = Config.CONFIG_DIR.resolve("hollowengine").resolve("console-history.json") + + private val loaded: ArrayList by lazy { ArrayList(read()) } + + override val entries: List get() = loaded + + override fun add(entry: String) { + if (loaded.lastOrNull() == entry) return + loaded += entry + if (loaded.size > MaxEntries) loaded.removeAt(0) + write() + } + + private fun read(): List { + val path = file + if (!path.exists()) return emptyList() + return try { + json.decodeFromString(serializer, path.readText()).takeLast(MaxEntries) + } catch (e: Exception) { + LOGGER.warn("Could not read the console history", e) + emptyList() + } + } + + private fun write() { + try { + val path = file + path.createParentDirectories() + path.writeText(json.encodeToString(serializer, loaded)) + } catch (e: Exception) { + LOGGER.warn("Could not write the console history", e) + } + } +} + +/** + * Up/Down through a [ConsoleHistoryStore], like the chat does it: the text typed before stepping + * back is kept and comes back when stepping past the newest entry. + */ +internal class ConsoleHistory(private val store: ConsoleHistoryStore) { + private var position = -1 + private var draft = "" + + /** The input one step [older] or newer than [current], or null when there is nowhere to go. */ + fun step(current: String, older: Boolean): String? { + val entries = store.entries + val target = if (older) position + 1 else position - 1 + if (target < -1 || target > entries.lastIndex) return null + if (position == -1) draft = current + position = target + return if (target == -1) draft else entries[entries.lastIndex - target] + } + + fun add(entry: String) { + store.add(entry) + reset() + } + + /** Back to the text being typed, for when the input changes under the history. */ + fun reset() { + position = -1 + draft = "" + } +} 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 8c7dc8ccb..5ee8263f8 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 @@ -5,10 +5,15 @@ import net.minecraft.client.Minecraft import org.apache.logging.log4j.spi.StandardLevel import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.docking.DockTags +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.scroll.rememberScrollState import ru.hollowhorizon.hollowengine.client.ui.widgets.EditableTextField import ru.hollowhorizon.hollowengine.client.ui.widgets.TextFieldState +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCodeFontFamily import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCheckboxVariant import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdown import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem @@ -30,9 +35,65 @@ import java.time.format.DateTimeFormatter private const val LANG = "hollowengine.gui.console" -/** Level, filter and auto-scroll, shown in the dock's tab bar next to the console's tab. */ +/** + * The bottom tool window: the log with its command line, and the problems of the file being edited. + */ +@Composable +internal fun HollowIdeConsolePanel(console: HollowIdeConsole, problems: HollowIdeProblems) { + Column( + tags = listOf("ide-panel", "console-panel"), + modifier = Modifier.size(100.percent, 100.percent), + ) { + ConsoleHeader(console, problems) + when (console.tab) { + ConsoleTab.LOGS -> { + ConsoleLog(console) + ConsoleInput(console) + } + + ConsoleTab.PROBLEMS -> HollowIdeProblemsList(problems) + } + } +} + +@Composable +private fun ConsoleHeader(console: HollowIdeConsole, problems: HollowIdeProblems) { + val parked = LocalDockPanelTitle.current + Row(id = parked?.headerId, tags = listOf("tool-window-header"), modifier = parked?.dragHandle ?: Modifier) { + parked?.let { panel -> + panel.icon?.let { icon -> Image(icon, tags = listOf(DockTags.PinnedHeaderIcon)) } + Text(panel.title, tags = listOf(DockTags.PinnedHeaderLabel, "tool-window-title")) + } + ConsoleTab.entries.forEach { tab -> + ConsoleTabButton(console, tab, if (tab == ConsoleTab.PROBLEMS) problems else null) + } + Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) + if (console.tab == ConsoleTab.LOGS) ConsoleToolbar(console) + } +} + +@Composable +private fun ConsoleTabButton(console: HollowIdeConsole, tab: ConsoleTab, problems: HollowIdeProblems?) { + Row( + id = "console-tab-${tab.name.lowercase()}", + tags = listOfNotNull("tool-window-tab", "selected".takeIf { console.tab == tab }), + modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND) + .onClick { event -> + console.tab = tab + event.consume() + }, + ) { + Text(tab.langKey.lang, tags = listOf("tool-window-tab-label")) + val count = problems?.diagnostics?.size ?: 0 + if (problems != null && count > 0) { + Text(count.toString(), tags = listOf("tool-window-tab-count", problems.severityTag)) + } + } +} + +/** Level, filter and auto-scroll of the log. */ @Composable -internal fun HollowIdeConsoleToolbar(console: HollowIdeConsole) { +private fun ConsoleToolbar(console: HollowIdeConsole) { var levelMenuExpanded by remember { mutableStateOf(false) } Row(tags = listOf("console-toolbar")) { UiDropdown( @@ -51,6 +112,7 @@ internal fun HollowIdeConsoleToolbar(console: HollowIdeConsole) { value = console.filterText, placeholder = "$LANG.filter_hint".lang, onChange = { console.filterText = it }, + textShadow = null, id = "console-filter", tags = listOf("console-filter"), modifier = Modifier.size(FilterWidth.px, 18.px), @@ -67,7 +129,7 @@ internal fun HollowIdeConsoleToolbar(console: HollowIdeConsole) { } @Composable -internal fun HollowIdeConsolePanel(console: HollowIdeConsole) { +private fun ConsoleLog(console: HollowIdeConsole) { var snapshot by remember { mutableStateOf(HollowLogStore.snapshot()) } val fontSize = HollowIdeConsoleFontSize.size @@ -86,7 +148,9 @@ internal fun HollowIdeConsolePanel(console: HollowIdeConsole) { val document = remember { ConsoleLogDocument() } val logState = remember { - TextFieldState(multiline = true, readOnly = true, autoPairs = false, indentSize = null, wrap = false) + TextFieldState( + multiline = true, readOnly = true, autoPairs = false, indentSize = null, wrap = false, textShadow = null, + ) } val revision = remember(visibleEntries) { document.sync(visibleEntries, ConsoleLogDocument.Key(minimumLevel, console.filterText)) @@ -109,33 +173,27 @@ internal fun HollowIdeConsolePanel(console: HollowIdeConsole) { } } - Column( - tags = listOf("ide-panel", "console-panel"), - modifier = Modifier.size(100.percent, 100.percent), - ) { - if (document.text.isEmpty()) { - Box(tags = listOf("console-empty"), modifier = Modifier.size(100.percent, 0.px).grow(1f)) { - Text("$LANG.empty".lang) - } - } else { - EditableTextField( - state = logState, - id = "console-log", - tags = listOf("console-log"), - scrollState = logScroll, - syntaxHighlighter = highlighter, - modifier = Modifier.size(100.percent, 0.px).grow(1f).onScroll { event -> - if (event.isCtrlDown()) { - HollowIdeConsoleFontSize.zoom(event.rawScrollY) - event.consume() - } else if (event.rawScrollY > 0f) { - console.autoScroll = false - } - }, - ) + if (document.text.isEmpty()) { + Box(tags = listOf("console-empty"), modifier = Modifier.size(100.percent, 0.px).grow(1f)) { + Text("$LANG.empty".lang) } - ConsoleInput(console) + return } + EditableTextField( + state = logState, + id = "console-log", + tags = listOf("console-log"), + scrollState = logScroll, + syntaxHighlighter = highlighter, + modifier = Modifier.size(100.percent, 0.px).grow(1f).onScroll { event -> + if (event.isCtrlDown()) { + HollowIdeConsoleFontSize.zoom(event.rawScrollY) + event.consume() + } else if (event.rawScrollY > 0f) { + console.autoScroll = false + } + }, + ) } @Composable @@ -180,6 +238,7 @@ private fun ConsoleInput(console: HollowIdeConsole) { autoPairs = kotlinMode, wrap = false, fontFamily = LogFontFamily, + textShadow = null, placeholder = when { kotlinMode -> "$LANG.kotlin_hint".lang connected -> "$LANG.command_hint".lang @@ -299,12 +358,12 @@ private class ConsoleLogDocument { private fun append(entry: HollowLogEntry) { if (builder.isNotEmpty()) builder.append('\n') val levelColors = ConsoleLevelColors.getValue(entry.level) - span(LogTimeFormatter.format(Instant.ofEpochMilli(entry.timeMillis)), MetaStyle) + span(LogTimeFormatter.format(Instant.ofEpochMilli(entry.timeMillis)), TimeStyle) builder.append(' ') span(entry.level.name.padEnd(LevelLabelWidth), levelColors.level) builder.append(' ') entry.loggerName?.let { logger -> - span(logger.substringAfterLast('.'), MetaStyle) + span(logger.substringAfterLast('.'), LoggerStyle) builder.append(' ') } span(entry.message.replace("\r\n", "\n").trimEnd(), levelColors.message) @@ -358,22 +417,24 @@ private val ConsoleLevels = listOf( ) private val MessageColor = UiColor.fromArgb(0xFFD7DEEA.toInt()) -private val MetaStyle = UiInlineStyle().withColor(UiColor.fromArgb(0xFF687588.toInt())) -private val ErrorColors = LevelColors(UiColor.fromArgb(0xFFFF8787.toInt()), UiColor.fromArgb(0xFFFF8787.toInt())) +private val QuietMessageColor = UiColor.fromArgb(0xFFA9B3C4.toInt()) +private val TimeStyle = UiInlineStyle().withColor(UiColor.fromArgb(0xFF4F5968.toInt())) +private val LoggerStyle = UiInlineStyle().withColor(UiColor.fromArgb(0xFF7A8698.toInt())) +private val ErrorColors = LevelColors(UiColor.fromArgb(0xFFF07878.toInt()), UiColor.fromArgb(0xFFF29A9A.toInt())) private val ConsoleLevelColors = mapOf( StandardLevel.OFF to LevelColors(MessageColor, MessageColor), StandardLevel.FATAL to ErrorColors, StandardLevel.ERROR to ErrorColors, - StandardLevel.WARN to LevelColors(UiColor.fromArgb(0xFFFFC266.toInt()), UiColor.fromArgb(0xFFFFC266.toInt())), - StandardLevel.INFO to LevelColors(UiColor.fromArgb(0xFF82D99B.toInt()), MessageColor), - StandardLevel.DEBUG to LevelColors(UiColor.fromArgb(0xFF78A8FF.toInt()), MessageColor), - StandardLevel.TRACE to LevelColors(UiColor.fromArgb(0xFF7F8A9D.toInt()), UiColor.fromArgb(0xFF7F8A9D.toInt())), + StandardLevel.WARN to LevelColors(UiColor.fromArgb(0xFFE6B35A.toInt()), UiColor.fromArgb(0xFFE8CA92.toInt())), + StandardLevel.INFO to LevelColors(UiColor.fromArgb(0xFF7CC49A.toInt()), MessageColor), + StandardLevel.DEBUG to LevelColors(UiColor.fromArgb(0xFF6E8FBF.toInt()), QuietMessageColor), + StandardLevel.TRACE to LevelColors(UiColor.fromArgb(0xFF5F6A7B.toInt()), UiColor.fromArgb(0xFF7A8698.toInt())), StandardLevel.ALL to LevelColors(MessageColor, MessageColor), ) private val LogTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") .withZone(ZoneId.systemDefault()) -private const val LogFontFamily = "hollowengine:fonts/monocraft" +private const val LogFontFamily = UiCodeFontFamily private const val LevelLabelWidth = 5 private const val FilterWidth = 140f private const val AutoScrollLayoutFrames = 2 @@ -381,6 +442,6 @@ private const val InputHeight = 22f private const val ExpandedInputHeight = 120f private const val RunIcon = "hollowengine:textures/gui/icons/play.svg" -private const val StopIcon = "hollowengine:textures/gui/icons/stop.png" +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" 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 621fbeb46..9d8200fb0 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 @@ -17,10 +17,16 @@ internal enum class ConsoleInputMode(val langKey: String) { COMMAND("hollowengine.gui.console.mode.command"), KOTLIN("hollowengine.gui.console.mode.kotlin"), } +/** The pages of the bottom tool window. */ +internal enum class ConsoleTab(val langKey: String) { + LOGS("hollowengine.gui.console.tab.logs"), PROBLEMS("hollowengine.gui.console.tab.problems"), +} + /** * The console's state, kept by the IDE rather than by the panel's composition. */ internal class HollowIdeConsole { + var tab by mutableStateOf(ConsoleTab.LOGS) var minimumLevel by mutableStateOf(StandardLevel.DEBUG) var filterText by mutableStateOf("") var autoScroll by mutableStateOf(true) @@ -34,8 +40,10 @@ internal class HollowIdeConsole { private set private val inputs = mutableStateMapOf() - private val history = ConsoleInputMode.entries.associateWith { ArrayList() } - private var historyIndex = -1 + private val history = mapOf( + ConsoleInputMode.COMMAND to ConsoleHistory(VanillaCommandHistory), + ConsoleInputMode.KOTLIN to ConsoleHistory(ConsoleSnippetHistory), + ) val commands = ConsoleCommandAssist { assistRevision++ } @@ -67,10 +75,10 @@ internal class HollowIdeConsole { fun run() { if (!canRun) return val text = input - addToHistory(text) when (mode) { ConsoleInputMode.COMMAND -> runCommand(text.trim()) ConsoleInputMode.KOTLIN -> { + history.getValue(mode).add(text) running = true ConsoleScripts.run(text) { running = false } } @@ -82,34 +90,18 @@ internal class HollowIdeConsole { /** Steps through earlier inputs of the current mode; [older] goes back in time. */ fun browseHistory(older: Boolean): Boolean { - val entries = history.getValue(mode) - if (entries.isEmpty()) return false - historyIndex = when { - older -> (historyIndex + 1).coerceAtMost(entries.lastIndex) - historyIndex <= 0 -> -1 - else -> historyIndex - 1 - } - input = if (historyIndex < 0) "" else entries[entries.lastIndex - historyIndex] + input = history.getValue(mode).step(input, older) ?: return false return true } - private fun addToHistory(text: String) { - val entries = history.getValue(mode) - entries.remove(text) - entries += text - if (entries.size > MaxHistory) entries.removeAt(0) - historyIndex = -1 - } - private fun runCommand(command: String) { val connection = Minecraft.getInstance().connection ?: return + history.getValue(ConsoleInputMode.COMMAND).add(command) connection.sendCommand(command.removePrefix("/")) HollowEngine.LOGGER.info("Executed command: {}", command) } private companion object { - const val MaxHistory = 50 - /** Named like a project script so imports resolve, with the console's own script type. */ val KotlinInputPath = "${SandboxScriptSource.SCRIPTS_DIRECTORY}/${ConsoleScripts.SCRIPT_NAME}" } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/GameViewportDock.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/GameViewportDock.kt index 0cfc7caa7..60273cffe 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/GameViewportDock.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/GameViewportDock.kt @@ -2,11 +2,11 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.panels import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import net.minecraft.client.Minecraft import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.GameViewportNodeId import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeGameViewport -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui.TimelineColors import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.utils.lang @@ -23,15 +23,21 @@ internal object GameViewportLang { */ @Composable fun GameViewportDock(active: Boolean = false, attached: Boolean = true) { + val placement = remember { PanelPlacement() } DisposableEffect(attached) { - if (!attached) HollowIdeGameViewport.report(null) - onDispose { HollowIdeGameViewport.report(null) } + val bounds = placement.bounds + if (attached && bounds != null) HollowIdeGameViewport.report(placement, bounds) + if (!attached) HollowIdeGameViewport.release(placement) + onDispose { HollowIdeGameViewport.release(placement) } } Box( id = GameViewportNodeId, tags = listOfNotNull("game-viewport", "active".takeIf { active && attached }), - modifier = Modifier.size(100.percent, 100.percent).input(hoverable = true, clickable = true) - .background(TimelineColors.Background).onPlaced { if (attached) HollowIdeGameViewport.report(it) } + modifier = Modifier.input(hoverable = true, clickable = true) + .onPlaced { + placement.bounds = it + if (attached) HollowIdeGameViewport.report(placement, it) + } .drawBehind(key = "game-viewport-$attached") { if (!attached) return@drawBehind val rect = HollowIdeGameViewport.imageRect(UiRect(0f, 0f, size.width, size.height)) ?: return@drawBehind @@ -52,3 +58,7 @@ fun GameViewportDock(active: Boolean = false, attached: Boolean = true) { } } } + +private class PanelPlacement { + var bounds: UiRect? = null +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/VanillaModelEditorPanel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/VanillaModelEditorPanel.kt index 0d95610ba..76f225241 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/VanillaModelEditorPanel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/panels/VanillaModelEditorPanel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.* import com.mojang.blaze3d.systems.RenderSystem import com.mojang.blaze3d.vertex.PoseStack import com.mojang.blaze3d.vertex.VertexSorting +import kotlinx.coroutines.isActive import net.minecraft.client.Minecraft import net.minecraft.client.renderer.LightTexture import net.minecraft.client.renderer.Sheets @@ -81,6 +82,9 @@ private fun VanillaModelToggle(icon: String, tooltip: String, active: Boolean, o @Composable private fun VanillaModelPreview(state: VanillaModelViewerState, modifier: Modifier = Modifier) { + LaunchedEffect(state) { + while (isActive) withFrameNanos(state.zoomSpring::advance) + } Box( modifier = Modifier .input(hoverable = true, draggable = true) @@ -97,8 +101,7 @@ private fun VanillaModelPreview(state: VanillaModelViewerState, modifier: Modifi event.consume() } .onScroll { event -> - val factor = if (event.scrollY > 0f) 0.9f else 1.1f - state.zoom = (state.zoom * factor).coerceIn(0.1f, 10f) + state.zoomSpring.scroll(event.scrollY) event.consume() } .drawBehind(key = state) { @@ -116,7 +119,8 @@ private class VanillaModelViewerState( ) { var yaw by mutableStateOf(35f) var pitch by mutableStateOf(25f) - var zoom by mutableStateOf(0.72f) + val zoomSpring = SpringZoom(0.72f, min = 0.1f, max = 10f, perNotch = ModelZoomPerNotch) + val zoom: Float get() = zoomSpring.value var offsetX by mutableStateOf(0f) var offsetY by mutableStateOf(0f) var showGrid by mutableStateOf(true) 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 0fd777a49..43cdabe60 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 @@ -25,7 +25,7 @@ data class ChannelValueOption( val labelKey: String, ) -/** Name, color and editor behaviour of one scalar component of a property. */ +/** Name, color and editor behavior of one scalar component of a property. */ data class ChannelSpec( val name: String, val color: Color, @@ -50,21 +50,6 @@ data class ChannelSpec( } } -/** How a layer's value is folded into layers below it produced. */ -enum class BlendMode { - /** Replaces the value below it (scaled by the layer's weight). */ - OVERRIDE, - - /** Adds on top, the mode for shakes, offsets and corrections. */ - ADD, - - /** Subtracts, the inverse of [ADD]. */ - SUBTRACT, - - /** Scales the value below it. */ - MULTIPLY, -} - /** A scalar keyframe on one channel curve. */ class Keyframe( var time: Float, @@ -203,20 +188,6 @@ class ChannelCurve(val spec: ChannelSpec) { } } -class AnimLayer(name: String, val channels: List) { - var nameState by mutableStateOf(name) - var isVisible by mutableStateOf(true) - var isLocked by mutableStateOf(false) - var isExpanded by mutableStateOf(true) - var blendMode by mutableStateOf(BlendMode.OVERRIDE) - var weight by mutableStateOf(1f) - - val keyframes: List get() = channels.flatMap { it.keyframes } - - fun curveOf(keyframe: Keyframe): ChannelCurve? = - channels.firstOrNull { curve -> curve.keyframes.any { it === keyframe } } -} - data class ChannelBounds(val minimum: Float? = null, val maximum: Float? = null) { fun clamp(value: Float): Float { var result = value @@ -230,6 +201,9 @@ data class ChannelBounds(val minimum: Float? = null, val maximum: Float? = null) } } +/** + * One animated property: a name, a type, and one curve per scalar component of it. + */ class AnimProperty( val id: String, name: String, @@ -241,62 +215,64 @@ class AnimProperty( var isExpanded by mutableStateOf(true) + /** Whether the curves of this property are drawn in the graph; it does not change the value. */ + var isVisible by mutableStateOf(true) + + /** A locked property cannot be edited, which is what keeps a finished track finished. */ + var isLocked by mutableStateOf(false) + + var isListed by mutableStateOf(true) + var type: PropertyType by mutableStateOf(type) private set - val layers = mutableStateListOf() + var curves: List = type.channels.map { ChannelCurve(it) } + private set val channels: List get() = type.channels - fun addLayer(name: String = "Layer ${layers.size + 1}", blendMode: BlendMode = BlendMode.OVERRIDE): AnimLayer { - val layer = AnimLayer(name, type.channels.map { ChannelCurve(it) }) - layer.blendMode = when { - layers.isEmpty() -> BlendMode.OVERRIDE - blendMode in type.blendModes -> blendMode - else -> type.blendModes.first() - } - layers.add(layer) - return layer - } + val keyframes: List get() = curves.flatMap { it.keyframes } fun retype(next: PropertyType) { if (next.channels == type.channels) { type = next return } - val resampled = layers.map { layer -> resample(layer, type, next) } + + val resampled = resample(type, next) type = next - val rebuilt = layers.mapIndexed { index, layer -> - val channels = next.channels.mapIndexed { channel, spec -> - ChannelCurve(spec).also { it.keyframes.addAll(resampled[index][channel]) } - } - AnimLayer(layer.nameState, channels).also { - it.blendMode = layer.blendMode - it.weight = layer.weight - it.isVisible = layer.isVisible - it.isLocked = layer.isLocked - it.isExpanded = layer.isExpanded - } + curves = next.channels.mapIndexed { channel, spec -> + ChannelCurve(spec).also { curve -> curve.keyframes.addAll(resampled[channel]) } } - layers.clear() - layers.addAll(rebuilt) } @Suppress("UNCHECKED_CAST") - internal fun restoreState(type: PropertyType<*>, layers: List) { + internal fun restoreState(type: PropertyType<*>, curves: List) { this.type = type as PropertyType - this.layers.clear() - this.layers.addAll(layers) + this.curves = curves } - private fun resample(layer: AnimLayer, from: PropertyType, to: PropertyType): List> { - val times = layer.channels.flatMap { curve -> curve.keyframes.map { it.time } } + /** + * Takes over the curves of [other], which has to be of the same type. + * + * Used when a property is rebound to a new target and its keys have to survive the swap. + */ + fun adoptCurves(other: AnimProperty<*>) { + if (other.type.channels != type.channels) return + curves = other.curves + isVisible = other.isVisible + isLocked = other.isLocked + } + + /** Keyframes carried over when the property changes type, such as euler to quaternion. */ + private fun resample(from: PropertyType, to: PropertyType): List> { + val times = curves.flatMap { curve -> curve.keyframes.map { it.time } } .distinctBy { round(it / ChannelCurve.KEY_TIME_EPSILON) }.sorted() val channelValues = to.channels.indices.map { mutableListOf() } val buffer = FloatArray(from.channels.size) times.forEach { time -> from.decompose(defaultValue, buffer) - layer.channels.forEachIndexed { index, curve -> + curves.forEachIndexed { index, curve -> buffer[index] = curve.valueAt(time, buffer[index]) } val value = from.compose(buffer) @@ -315,24 +291,14 @@ class AnimProperty( val size = type.channels.size val values = FloatArray(size) type.decompose(defaultValue, values) - layers.forEach { layer -> - if (!layer.isVisible) return@forEach - val weight = layer.weight - if (weight == 0f) return@forEach - for (channel in 0 until size) { - val curve = layer.channels.getOrNull(channel) ?: continue - if (!curve.isVisible) continue - val base = values[channel] - if (curve.spec.sampling == ChannelSampling.DISCRETE) { - values[channel] = curve.spec.normalize(curve.valueAt(time, base)) - } else { - val neutral = layer.blendMode.neutral(base) - val sampled = curve.valueAt(time, neutral) - values[channel] = layer.blendMode.blend(base, sampled, weight) - } - } + for (channel in 0 until size) { + val curve = curves.getOrNull(channel) ?: continue + val base = values[channel] + val sampled = curve.valueAt(time, base) + values[channel] = type.bounds(channel).clamp( + if (curve.spec.sampling == ChannelSampling.DISCRETE) curve.spec.normalize(sampled) else sampled + ) } - for (channel in 0 until size) values[channel] = type.bounds(channel).clamp(values[channel]) return type.compose(values) } @@ -340,21 +306,8 @@ class AnimProperty( apply?.invoke(valueAt(time)) } - fun curveOf(keyframe: Keyframe): ChannelCurve? = layers.firstNotNullOfOrNull { it.curveOf(keyframe) } - -} - -private fun BlendMode.neutral(base: Float): Float = when (this) { - BlendMode.OVERRIDE -> base - BlendMode.ADD, BlendMode.SUBTRACT -> 0f - BlendMode.MULTIPLY -> 1f -} - -private fun BlendMode.blend(base: Float, value: Float, weight: Float): Float = when (this) { - BlendMode.OVERRIDE -> base + (value - base) * weight - BlendMode.ADD -> base + value * weight - BlendMode.SUBTRACT -> base - value * weight - BlendMode.MULTIPLY -> base * (1f + (value - 1f) * weight) + fun curveOf(keyframe: Keyframe): ChannelCurve? = + curves.firstOrNull { curve -> curve.keyframes.any { it === keyframe } } } interface PropertyType { @@ -364,7 +317,6 @@ interface PropertyType { fun bounds(channel: Int): ChannelBounds = ChannelBounds.Unbounded val isChannelSpaceLinear: Boolean get() = true - val blendModes: Set get() = setOf(BlendMode.OVERRIDE, BlendMode.ADD, BlendMode.SUBTRACT) fun decompose(value: T, into: FloatArray) fun compose(values: FloatArray): T @@ -376,6 +328,13 @@ class TrackGroup(name: String) { var isLocked by mutableStateOf(false) var isVisible by mutableStateOf(true) + /** Whether the group row stays in the list when none of its properties is listed. */ + var isListed by mutableStateOf(true) + + /** Whether anything of this group has a row, which is what decides whether the group has one. */ + val hasListedContent: Boolean + get() = isListed || properties.any { it.isListed } || children.any { it.hasListedContent } + val children = mutableStateListOf() val properties = mutableStateListOf>() 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 a09733c75..663057b87 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 @@ -51,8 +51,6 @@ class TimelineController { var isWorkAreaSelected by mutableStateOf(false) var isCameraPreviewEnabled by mutableStateOf(false) - var activeLayer by mutableStateOf(null) - val history = TimelineHistory(this) var onChanged: (() -> Unit)? = null @@ -61,6 +59,9 @@ class TimelineController { var captureExtraState: (() -> Any?)? = null var restoreExtraState: ((Any?) -> Unit)? = null + var onHideProperty: ((AnimProperty<*>) -> Unit)? = null + var onHideGroup: ((TrackGroup) -> Unit)? = null + fun group(path: List): TrackGroup { require(path.isNotEmpty()) { "Timeline group path must not be empty" } var currentGroups: MutableList = groups @@ -74,35 +75,26 @@ class TimelineController { fun addProperty(path: List, property: AnimProperty): AnimProperty { group(path).properties.add(property) - if (property.layers.isEmpty()) property.addLayer(BASE_LAYER_NAME) - if (activeLayer == null) activeLayer = property.layers.firstOrNull() return property } fun allProperties(): List> = groups.flatMap { it.allProperties() } - fun allLayers(): List = allProperties().flatMap { it.layers } - - fun allCurves(): List = allLayers().flatMap { it.channels } + fun allCurves(): List = allProperties().flatMap { it.curves } - fun propertyOf(layer: AnimLayer): AnimProperty<*>? = allProperties().firstOrNull { layer in it.layers } - - fun targetLayer(property: AnimProperty<*>): AnimLayer? { - val active = activeLayer - if (active != null && active in property.layers) return active - return property.layers.firstOrNull() - } + fun propertyOf(curve: ChannelCurve): AnimProperty<*>? = + allProperties().firstOrNull { property -> property.curves.any { it === curve } } - fun layerOf(keyframe: Keyframe): AnimLayer? = allLayers().firstOrNull { it.curveOf(keyframe) != null } + fun propertyOf(keyframe: Keyframe): AnimProperty<*>? = + allProperties().firstOrNull { it.curveOf(keyframe) != null } fun curveOf(keyframe: Keyframe): ChannelCurve? = allCurves().firstOrNull { curve -> curve.keyframes.any { it === keyframe } } - fun isLocked(layer: AnimLayer): Boolean { - if (layer.isLocked) return true - val owner = propertyOf(layer) ?: return false - return groupOf(owner)?.let { isLocked(it) } == true + fun isLocked(property: AnimProperty<*>): Boolean { + if (property.isLocked) return true + return groupOf(property)?.let { isLocked(it) } == true } fun groupOf(property: AnimProperty<*>): TrackGroup? { @@ -152,6 +144,15 @@ class TimelineController { onTimeChanged?.invoke() } + /** + * Moves the playhead to where whatever is being edited has actually got to. + */ + fun followPlayhead(time: Float) { + val end = workAreaEnd + currentTime = if (end > 0f) time.mod(end) else time.coerceAtLeast(0f) + allProperties().forEach { it.update(currentTime) } + } + fun applyCameraPreviewEnabled(isEnabled: Boolean) { isCameraPreviewEnabled = isEnabled onPreviewChanged?.invoke() @@ -161,6 +162,34 @@ class TimelineController { isPlaying = !isPlaying } + /** + * Bumped to ask the window to fit the work area into the lanes; only the window knows how wide + * they are. + */ + var frameTimeRequests by mutableStateOf(0) + private set + + fun requestFrameTime() { + frameTimeRequests++ + } + + /** + * Moves the playhead to the nearest key after it ([direction] > 0) or before it, on the tracks the + * list shows. Returns false when there is none that way. + */ + fun jumpToKey(direction: Int): Boolean { + val now = currentTime + val times = allProperties().filter { it.isListed }.flatMap { it.curves }.flatMap { it.keyframes }.map { it.time } + val target = if (direction > 0) { + times.filter { it > now + KEYFRAME_TIME_EPSILON }.minOrNull() + } else { + times.filter { it < now - KEYFRAME_TIME_EPSILON }.maxOrNull() + } ?: return false + isPlaying = false + applyCurrentTime(target) + return true + } + fun isSelected(keyframe: Keyframe): Boolean = selectedKeyframes.any { it === keyframe } fun clearSelection() { @@ -223,9 +252,9 @@ class TimelineController { fun deleteSelectedKeyframes() { edit("Delete keyframes") { val doomed = selectedKeyframes.toList() - allLayers().forEach { layer -> - if (isLocked(layer)) return@forEach - layer.channels.forEach { curve -> + allProperties().forEach { property -> + if (isLocked(property)) return@forEach + property.curves.forEach { curve -> curve.keyframes.removeAll { key -> doomed.any { it === key } } } } @@ -262,9 +291,9 @@ class TimelineController { val delta = value - reference.value edit("Edit keyframe value") { selectedKeyframes.forEach { key -> - val layer = layerOf(key) ?: return@forEach - if (isLocked(layer)) return@forEach - val curve = layer.curveOf(key) ?: return@forEach + val property = propertyOf(key) ?: return@forEach + if (isLocked(property)) return@forEach + val curve = property.curveOf(key) ?: return@forEach val candidate = if (referenceCurve.spec.sampling == ChannelSampling.DISCRETE) { if (curve.spec.valueOptions != referenceCurve.spec.valueOptions) return@forEach value @@ -276,13 +305,11 @@ class TimelineController { } } - fun addKeyframes(layer: AnimLayer, time: Float): List { - if (isLocked(layer)) return emptyList() + fun addKeyframes(property: AnimProperty<*>, time: Float): List { + if (isLocked(property)) return emptyList() return edited("Add keyframe") { - val property = propertyOf(layer) - layer.channels.mapIndexed { channel, curve -> - val fallback = property?.let { defaultChannel(it, channel) } ?: 0f - setKey(curve, time, curve.valueAt(time, fallback), selectKey = false) + property.curves.mapIndexed { channel, curve -> + setKey(curve, time, curve.valueAt(time, defaultChannel(property, channel)), selectKey = false) }.also { select(it, additive = false) } } } @@ -321,8 +348,8 @@ class TimelineController { val created = mutableListOf() clipboard.forEach { clip -> if (live.none { it === clip.curve }) return@forEach - val layer = layerOfCurve(clip.curve) - if (layer != null && isLocked(layer)) return@forEach + val owner = propertyOf(clip.curve) + if (owner != null && isLocked(owner)) return@forEach val target = (time + clip.offset).coerceIn(0f, workAreaEnd) clip.curve.keyframes.removeAll { abs(it.time - target) <= KEYFRAME_TIME_EPSILON } val key = clip.state.toKeyframe().also { @@ -339,18 +366,12 @@ class TimelineController { private fun boundsOf(curve: ChannelCurve?): ChannelBounds { curve ?: return ChannelBounds.Unbounded - val property = allProperties().firstOrNull { owner -> - owner.layers.any { layer -> layer.channels.any { it === curve } } - } ?: return ChannelBounds.Unbounded - val channel = property.layers.firstNotNullOfOrNull { layer -> - layer.channels.indexOfFirst { it === curve }.takeIf { it >= 0 } - } ?: return ChannelBounds.Unbounded + val property = propertyOf(curve) ?: return ChannelBounds.Unbounded + val channel = property.curves.indexOfFirst { it === curve }.takeIf { it >= 0 } + ?: return ChannelBounds.Unbounded return property.bounds(channel) } - private fun layerOfCurve(curve: ChannelCurve): AnimLayer? = - allLayers().firstOrNull { layer -> layer.channels.any { it === curve } } - fun duplicateSelectedKeyframes() { val originals = selectedKeyframes.toList() @@ -359,8 +380,8 @@ class TimelineController { val created = mutableListOf() originals.forEach { original -> val curve = curveOf(original) ?: return@forEach - val layer = layerOf(original) ?: return@forEach - if (isLocked(layer)) return@forEach + val owner = propertyOf(original) ?: return@forEach + if (isLocked(owner)) return@forEach val target = findFreeTime(curve, original.time + KEYFRAME_TIME_EPSILON * 2f) if (curve.keyAt(target) != null) return@forEach val copy = original.copy(target) @@ -397,8 +418,8 @@ class TimelineController { val clones = LinkedHashMap() originals.forEach { original -> val curve = curveOf(original) ?: return@forEach - val layer = layerOf(original) ?: return@forEach - if (isLocked(layer)) return@forEach + val owner = propertyOf(original) ?: return@forEach + if (isLocked(owner)) return@forEach val target = findFreeTime(curve, original.time + KEYFRAME_TIME_EPSILON * 2f) if (curve.keyAt(target) != null) return@forEach val copy = original.copy(target) @@ -578,9 +599,9 @@ class TimelineController { fun frameCurves() { var lowest = Float.MAX_VALUE var highest = -Float.MAX_VALUE - allLayers().forEach { layer -> - if (!layer.isVisible) return@forEach - layer.channels.forEach { curve -> + allProperties().forEach { property -> + if (!property.isVisible) return@forEach + property.curves.forEach { curve -> if (!curve.isVisible) return@forEach if (!curve.spec.supportsCurveEditor) return@forEach if (focusedCurves.isNotEmpty() && !isFocused(curve)) return@forEach @@ -623,13 +644,9 @@ class TimelineController { PropertySnapshot( property = property, type = property.type, - layers = property.layers.map { layer -> - LayerSnapshot( - layer = layer, - state = LayerState.of(layer), - curves = layer.channels.map { curve -> curve.keyframes.map { KeyframeState.of(it) } }, - ) - }, + state = PropertyState.of(property), + curves = property.curves, + keys = property.curves.map { curve -> curve.keyframes.map { KeyframeState.of(it) } }, ) }, currentTime = currentTime, @@ -639,24 +656,17 @@ class TimelineController { internal fun restoreSnapshot(snapshot: TimelineSnapshot) { snapshot.properties.forEach { propertySnapshot -> - propertySnapshot.layers.forEach { layerSnapshot -> - layerSnapshot.state.applyTo(layerSnapshot.layer) - layerSnapshot.layer.channels.forEachIndexed { index, curve -> - curve.keyframes.clear() - layerSnapshot.curves.getOrNull(index) - ?.let { keys -> curve.keyframes.addAll(keys.map { it.toKeyframe() }) } - } + propertySnapshot.state.applyTo(propertySnapshot.property) + propertySnapshot.curves.forEachIndexed { index, curve -> + curve.keyframes.clear() + propertySnapshot.keys.getOrNull(index) + ?.let { keys -> curve.keyframes.addAll(keys.map { it.toKeyframe() }) } } - propertySnapshot.property.restoreState( - propertySnapshot.type, - propertySnapshot.layers.map { it.layer }, - ) + propertySnapshot.property.restoreState(propertySnapshot.type, propertySnapshot.curves) } selectedKeyframes.clear() val liveCurves = allCurves() focusedCurves.retainAll { curve -> liveCurves.any { it === curve } } - val liveLayers = allLayers() - if (liveLayers.none { it === activeLayer }) activeLayer = liveLayers.firstOrNull() currentTime = snapshot.currentTime workAreaEnd = snapshot.workAreaEnd restoreExtraState?.invoke(snapshot.extra) @@ -677,7 +687,6 @@ class TimelineController { } } -internal const val BASE_LAYER_NAME = "Base" internal 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/TimelineHistory.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineHistory.kt index 82cc30a14..d6655dd7e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineHistory.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineHistory.kt @@ -22,42 +22,32 @@ internal data class KeyframeState( } } -internal data class LayerState( +internal data class PropertyState( val name: String, - val blendMode: BlendMode, - val weight: Float, val visible: Boolean, val locked: Boolean, ) { - fun applyTo(layer: AnimLayer) { - layer.nameState = name - layer.blendMode = blendMode - layer.weight = weight - layer.isVisible = visible - layer.isLocked = locked + fun applyTo(property: AnimProperty<*>) { + property.nameState = name + property.isVisible = visible + property.isLocked = locked } companion object { - fun of(layer: AnimLayer) = LayerState( - name = layer.nameState, - blendMode = layer.blendMode, - weight = layer.weight, - visible = layer.isVisible, - locked = layer.isLocked, + fun of(property: AnimProperty<*>) = PropertyState( + name = property.nameState, + visible = property.isVisible, + locked = property.isLocked, ) } } -internal data class LayerSnapshot( - val layer: AnimLayer, - val state: LayerState, - val curves: List>, -) - internal data class PropertySnapshot( val property: AnimProperty<*>, val type: PropertyType<*>, - val layers: List, + val state: PropertyState, + val curves: List, + val keys: List>, ) internal data class TimelineSnapshot( 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 new file mode 100644 index 000000000..d8b27a94d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineKeys.kt @@ -0,0 +1,108 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline + +import org.lwjgl.glfw.GLFW + +/** + * The keyboard of the timeline window, whatever it is showing. + */ +object TimelineKeys { + private const val NUDGE_SMALL = 0.05f + private const val NUDGE_LARGE = 0.25f + + fun handle(timeline: TimelineController, key: Int, modifiers: Int): Boolean { + val ctrl = modifiers and GLFW.GLFW_MOD_CONTROL != 0 + val shift = modifiers and GLFW.GLFW_MOD_SHIFT != 0 + return when { + ctrl && key == GLFW.GLFW_KEY_Z -> { + if (shift) timeline.redo() else timeline.undo() + true + } + + ctrl && key == GLFW.GLFW_KEY_Y -> { + timeline.redo() + true + } + + ctrl && key == GLFW.GLFW_KEY_TAB -> { + if (timeline.viewMode == TimelineViewMode.CURVES) timeline.viewMode = TimelineViewMode.DOPE_SHEET + else timeline.enterCurveView() + true + } + + key == GLFW.GLFW_KEY_F -> { + if (timeline.viewMode == TimelineViewMode.CURVES) timeline.frameCurves() + timeline.requestFrameTime() + true + } + + key == GLFW.GLFW_KEY_UP -> timeline.jumpToKey(1) + + key == GLFW.GLFW_KEY_DOWN -> timeline.jumpToKey(-1) + + key == GLFW.GLFW_KEY_S -> { + timeline.smoothSelectedKeyframes() + true + } + + key == GLFW.GLFW_KEY_DELETE || key == GLFW.GLFW_KEY_BACKSPACE -> { + timeline.deleteSelectedKeyframes() + true + } + + ctrl && key == GLFW.GLFW_KEY_D -> { + timeline.duplicateSelectedKeyframes() + true + } + + ctrl && key == GLFW.GLFW_KEY_C -> { + timeline.copySelectedKeyframes() + true + } + + ctrl && key == GLFW.GLFW_KEY_X -> { + timeline.cutSelectedKeyframes() + true + } + + ctrl && key == GLFW.GLFW_KEY_V -> { + timeline.pasteKeyframes() + true + } + + key == GLFW.GLFW_KEY_LEFT -> { + moveSelectionOrPlayhead(timeline, if (shift) -NUDGE_LARGE else -NUDGE_SMALL) + true + } + + key == GLFW.GLFW_KEY_RIGHT -> { + moveSelectionOrPlayhead(timeline, if (shift) NUDGE_LARGE else NUDGE_SMALL) + true + } + + key == GLFW.GLFW_KEY_ESCAPE -> { + timeline.clearSelection() + true + } + + key == GLFW.GLFW_KEY_HOME -> { + timeline.applyCurrentTime(0f) + true + } + + key == GLFW.GLFW_KEY_SPACE -> { + timeline.togglePlayback() + true + } + + else -> false + } + } + + private fun moveSelectionOrPlayhead(timeline: TimelineController, deltaSeconds: Float) { + if (timeline.selectedKeyframes.isEmpty()) { + timeline.applyCurrentTime(timeline.currentTime + deltaSeconds) + } else { + timeline.nudgeSelectedKeyframes(deltaSeconds) + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineTypes.kt index 99b86bc53..683513b38 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineTypes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/TimelineTypes.kt @@ -20,7 +20,6 @@ class FloatPropertyType( ) : PropertyType { override val id = ID override val channels = listOf(ChannelSpec(name, ChannelColors.SCALAR)) - override val blendModes = setOf(BlendMode.OVERRIDE, BlendMode.ADD, BlendMode.SUBTRACT, BlendMode.MULTIPLY) override fun bounds(channel: Int) = bounds @@ -43,7 +42,6 @@ class TranslationPropertyType : PropertyType { ChannelSpec("Y", ChannelColors.Y), ChannelSpec("Z", ChannelColors.Z), ) - override val blendModes = setOf(BlendMode.OVERRIDE, BlendMode.ADD, BlendMode.SUBTRACT, BlendMode.MULTIPLY) override fun decompose(value: Vec3f, into: FloatArray) { into[0] = value.x @@ -80,7 +78,6 @@ class RotationPropertyType(val mode: RotationMode = RotationMode.EULER) : Proper ) } - override val blendModes = setOf(BlendMode.OVERRIDE, BlendMode.ADD, BlendMode.SUBTRACT) override val isChannelSpaceLinear = mode == RotationMode.EULER diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt index 0d3a2809e..9a5a76eca 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CameraCutscene.kt @@ -96,7 +96,6 @@ class CutscenePlaybackController { fun setupTracks(data: CutsceneData, loop: Boolean = false, anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED) { timeline.groups.clear() timeline.clearSelection() - timeline.activeLayer = null timeline.workAreaEnd = max(0.1f, data.duration) timeline.currentTime = 0f isLooping = loop @@ -158,45 +157,40 @@ class CutscenePlaybackController { fun reanchor(position: Vec3f, yaw: Float) { val previous = origin.frame val next = origin.moved(position, yaw).frame - translation.layers.filter { it.blendMode == BlendMode.OVERRIDE }.forEach { layer -> - rebaseLayer(layer, translation) { local -> next.toLocal(previous.toWorld(local)) } - } - rotation.layers.filter { it.blendMode == BlendMode.OVERRIDE }.forEach { layer -> - rebaseLayer(layer, rotation) { local -> next.toLocalRotation(previous.toWorldRotation(local)) } - } + rebase(translation) { local -> next.toLocal(previous.toWorld(local)) } + rebase(rotation) { local -> next.toLocalRotation(previous.toWorldRotation(local)) } origin = origin.moved(position, yaw) updateProperties() } /** - * Resamples the keys of a single layer using [convert] + * Resamples the keys of a property using [convert] */ - private fun rebaseLayer(layer: AnimLayer, property: AnimProperty, convert: (T) -> T) { + private fun rebase(property: AnimProperty, convert: (T) -> T) { val type = property.type val size = type.channels.size - val times = layer.channels.flatMap { curve -> curve.keyframes.map { it.time } }.distinct().sorted() + val times = property.curves.flatMap { curve -> curve.keyframes.map { it.time } }.distinct().sorted() if (times.isEmpty()) return val buffer = FloatArray(size) val converted = times.associateWith { time -> property.decomposeDefault(buffer) - layer.channels.forEachIndexed { index, curve -> buffer[index] = curve.valueAt(time, buffer[index]) } + property.curves.forEachIndexed { index, curve -> buffer[index] = curve.valueAt(time, buffer[index]) } val next = FloatArray(size) type.decompose(convert(type.compose(buffer)), next) next } - layer.channels.forEachIndexed { index, curve -> + property.curves.forEachIndexed { index, curve -> converted.forEach { (time, values) -> val key = curve.keyAt(time) ?: Keyframe(time, 0f).also { curve.keyframes.add(it) } key.value = values.getOrElse(index) { key.value } } curve.sort() } - rotateTangents(layer, property, convert, times) + rotateTangents(property, convert, times) } private fun rotateTangents( - layer: AnimLayer, property: AnimProperty, convert: (T) -> T, times: List, @@ -209,7 +203,7 @@ class CutscenePlaybackController { val incoming = FloatArray(size) val outgoing = FloatArray(size) times.forEach { time -> - val keys = layer.channels.map { it.keyAt(time) } + val keys = property.curves.map { it.keyAt(time) } if (keys.all { it == null }) return@forEach keys.forEachIndexed { index, key -> incoming[index] = key?.incoming?.value ?: 0f @@ -274,7 +268,6 @@ class CutscenePlaybackController { WeatherPropertyType(), CutsceneWeather.CLEAR, ) { environmentWeather = it } - if (timeline.activeLayer == null) timeline.activeLayer = translation.layers.firstOrNull() } @Suppress("UNCHECKED_CAST") @@ -289,9 +282,8 @@ class CutscenePlaybackController { val existing = timeline.allProperties().firstOrNull { it.id == id } if (existing != null && existing.type.id == type.id) { val bound = AnimProperty(id, existing.nameState, existing.type as PropertyType, defaultValue, apply) - bound.layers.addAll(existing.layers) + bound.adoptCurves(existing) bound.isExpanded = existing.isExpanded - if (bound.layers.isEmpty()) bound.addLayer(BASE_LAYER_NAME) val group = timeline.groupOf(existing) ?: timeline.group(path) group.properties[group.properties.indexOf(existing)] = bound return bound @@ -348,17 +340,13 @@ private fun createProperty(id: String, name: String, data: CutscenePropertyData) else -> AnimProperty(id, name, FloatPropertyType(name), 0f) } - data.layers.forEach { layerData -> - val layer = property.addLayer(layerData.name, CutsceneEnums.blendMode(layerData.blend)) - layer.weight = layerData.weight - layer.isVisible = layerData.visible - layer.isLocked = layerData.locked - layerData.curves.forEach { curveData -> - val curve = layer.channels.firstOrNull { it.name == curveData.channel } ?: return@forEach - curve.isVisible = curveData.visible - curveData.keyframes.forEach { curve.keyframes.add(it.toKeyframe(curve.spec)) } - curve.sort() - } + property.isVisible = data.visible + property.isLocked = data.locked + data.curves.forEach { curveData -> + val curve = property.curves.firstOrNull { it.name == curveData.channel } ?: return@forEach + curve.isVisible = curveData.visible + curveData.keyframes.forEach { curve.keyframes.add(it.toKeyframe(curve.spec)) } + curve.sort() } return property } @@ -376,11 +364,8 @@ private fun CutsceneKeyData.toKeyframe(spec: ChannelSpec) = Keyframe( outgoing = KeyTangent(outTime, outValue), ) -private fun AnimProperty<*>.hasVisibleKeys(): Boolean = layers.any { layer -> - layer.isVisible && layer.weight != 0f && layer.channels.any { curve -> - curve.isVisible && curve.keyframes.isNotEmpty() - } -} +private fun AnimProperty<*>.hasVisibleKeys(): Boolean = + curves.any { curve -> curve.isVisible && curve.keyframes.isNotEmpty() } private fun TrackGroup.toNode(): CutsceneNodeData = CutsceneNodeData( id = nameState, @@ -396,19 +381,12 @@ private fun AnimProperty<*>.toNode(): CutsceneNodeData = CutsceneNodeData( property = CutscenePropertyData( type = type.id, rotationMode = CutsceneEnums.nameOf((type as? RotationPropertyType)?.mode ?: RotationMode.EULER), - layers = layers.map { it.toData() }, + visible = isVisible, + locked = isLocked, + curves = curves.map { it.toData() }, ), ) -private fun AnimLayer.toData() = CutsceneLayerData( - name = nameState, - blend = CutsceneEnums.nameOf(blendMode), - weight = weight, - visible = isVisible, - locked = isLocked, - curves = channels.map { it.toData() }, -) - private fun ChannelCurve.toData() = CutsceneCurveData( channel = name, visible = isVisible, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt index dfa0732bd..74ee4544e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneCameraSystem.kt @@ -19,14 +19,21 @@ object CutsceneCameraSystem { get() = controller /** - * The pose the camera should take. 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 both - * arrive through the same path. + * 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() = controller?.currentPose ?: StoryCameraSystem.currentPose + get() = heldPose ?: controller?.currentPose ?: StoryCameraSystem.currentPose - /** Whether anything is holding the camera, a cutscene or a story. */ + /** Whether anything is holding the camera: a held pose, a cutscene or a story. */ val isOverriding: Boolean get() = currentPose != null fun play(data: CutsceneData, loop: Boolean = false, anchor: CutsceneAnchor = CutsceneAnchor.WHERE_RECORDED) { @@ -100,6 +107,6 @@ object CutsceneCameraSystem { @SubscribeEvent fun onRenderHand(event: RenderItemInHandEvent) { - if (controller != null) event.isCanceled = true + 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/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt index 12751c230..5775fa1c2 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneData.kt @@ -1,7 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene import kotlinx.serialization.Serializable -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.BlendMode import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.HandleMode import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.KeyInterpolation import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.RotationMode @@ -30,21 +29,16 @@ data class CutsceneCurveData( val keyframes: List = emptyList(), ) -@Serializable -data class CutsceneLayerData( - val name: String, - val blend: String = CutsceneEnums.DEFAULT_BLEND, - val weight: Float = 1f, - val visible: Boolean = true, - val locked: Boolean = false, - val curves: List = emptyList(), -) - +/** + * One animated property: one curve per channel. + */ @Serializable data class CutscenePropertyData( val type: String, val rotationMode: String = CutsceneEnums.DEFAULT_ROTATION_MODE, - val layers: List = emptyList(), + val visible: Boolean = true, + val locked: Boolean = false, + val curves: List = emptyList(), ) @Serializable @@ -67,7 +61,6 @@ data class CutsceneData( object CutsceneEnums { const val DEFAULT_INTERPOLATION = "bezier" const val DEFAULT_HANDLES = "auto" - const val DEFAULT_BLEND = "override" const val DEFAULT_ROTATION_MODE = "euler" private val interpolations = mapOf( @@ -83,13 +76,6 @@ object CutsceneEnums { "free" to HandleMode.FREE, ) - private val blends = mapOf( - DEFAULT_BLEND to BlendMode.OVERRIDE, - "add" to BlendMode.ADD, - "subtract" to BlendMode.SUBTRACT, - "multiply" to BlendMode.MULTIPLY, - ) - private val rotationModes = mapOf( DEFAULT_ROTATION_MODE to RotationMode.EULER, "quaternion" to RotationMode.QUATERNION, @@ -103,10 +89,6 @@ object CutsceneEnums { fun nameOf(value: HandleMode): String = handles.entries.first { it.value == value }.key - fun blendMode(name: String): BlendMode = blends[name] ?: BlendMode.OVERRIDE - - fun nameOf(value: BlendMode): String = blends.entries.first { it.value == value }.key - fun rotationMode(name: String): RotationMode = rotationModes[name] ?: RotationMode.EULER fun nameOf(value: RotationMode): String = rotationModes.entries.first { it.value == value }.key diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt index 464b9e645..edcd3cb0d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEditorSession.kt @@ -65,9 +65,8 @@ class CutsceneEditorSession { } private fun writeChannels(property: AnimProperty<*>, time: Float, values: List) { - val layer = layerFor(property) ?: return - if (timeline.isLocked(layer)) return - val created = layer.channels.mapIndexedNotNull { channel, curve -> + if (timeline.isLocked(property)) return + val created = property.curves.mapIndexedNotNull { channel, curve -> val value = values.getOrNull(channel) ?: return@mapIndexedNotNull null val unwrapped = curve.spec.unwrap(value, curve.valueAt(time, value)) timeline.setKey(curve, time, unwrapped, selectKey = false) @@ -75,8 +74,6 @@ class CutsceneEditorSession { timeline.select(created, additive = true) } - fun layerFor(property: AnimProperty<*>): AnimLayer? = timeline.targetLayer(property) - fun moveOrigin(position: Vec3f, yaw: Float, keepWorld: Boolean) { timeline.edit(if (keepWorld) "Re-anchor cutscene" else "Move cutscene") { if (keepWorld) playback.reanchor(position, yaw) @@ -93,99 +90,11 @@ class CutsceneEditorSession { } fun onHollowUiKey(key: Int, modifiers: Int): Boolean { - val ctrl = modifiers and GLFW.GLFW_MOD_CONTROL != 0 - val shift = modifiers and GLFW.GLFW_MOD_SHIFT != 0 - val handled = when { - ctrl && key == GLFW.GLFW_KEY_Z -> { - if (shift) timeline.redo() else timeline.undo() - true - } - - ctrl && key == GLFW.GLFW_KEY_Y -> { - timeline.redo() - true - } - - ctrl && key == GLFW.GLFW_KEY_TAB -> { - if (timeline.viewMode == TimelineViewMode.CURVES) timeline.viewMode = TimelineViewMode.DOPE_SHEET - else timeline.enterCurveView() - true - } - - key == GLFW.GLFW_KEY_F -> { - timeline.frameCurves() - true - } - - key == GLFW.GLFW_KEY_S -> { - timeline.smoothSelectedKeyframes() - true - } - - key == GLFW.GLFW_KEY_DELETE || key == GLFW.GLFW_KEY_BACKSPACE -> { - timeline.deleteSelectedKeyframes() - true - } - - ctrl && key == GLFW.GLFW_KEY_D -> { - timeline.duplicateSelectedKeyframes() - true - } - - ctrl && key == GLFW.GLFW_KEY_C -> { - timeline.copySelectedKeyframes() - true - } - - ctrl && key == GLFW.GLFW_KEY_X -> { - timeline.cutSelectedKeyframes() - true - } - - ctrl && key == GLFW.GLFW_KEY_V -> { - timeline.pasteKeyframes() - true - } - - key == GLFW.GLFW_KEY_LEFT -> { - moveSelectionOrPlayhead(if (shift) -KEYFRAME_NUDGE_LARGE else -KEYFRAME_NUDGE_SMALL) - true - } - - key == GLFW.GLFW_KEY_RIGHT -> { - moveSelectionOrPlayhead(if (shift) KEYFRAME_NUDGE_LARGE else KEYFRAME_NUDGE_SMALL) - true - } - - key == GLFW.GLFW_KEY_ESCAPE -> { - timeline.clearSelection() - true - } - - key == GLFW.GLFW_KEY_HOME -> { - timeline.applyCurrentTime(0f) - true - } - - key == GLFW.GLFW_KEY_SPACE -> { - timeline.togglePlayback() - true - } - - else -> false - } + val handled = TimelineKeys.handle(timeline, key, modifiers) if (handled) invalidateUi() return handled } - private fun moveSelectionOrPlayhead(deltaSeconds: Float) { - if (timeline.selectedKeyframes.isEmpty()) { - timeline.applyCurrentTime(timeline.currentTime + deltaSeconds) - } else { - timeline.nudgeSelectedKeyframes(deltaSeconds) - } - } - fun syncPlaybackFromTimeline() { playback.updateProperties() updatePreviewState() @@ -216,8 +125,6 @@ class CutsceneEditorSession { fun channelValues(property: AnimProperty<*>): List = property.decomposeAt(timeline.currentTime).toList() } -private const val KEYFRAME_NUDGE_SMALL = 0.05f -private const val KEYFRAME_NUDGE_LARGE = 0.25f object CutsceneEditorSessions { val default = CutsceneEditorSession() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt index dce337f15..4a76a133a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneEnvironment.kt @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene import net.minecraft.client.Minecraft import net.minecraft.client.multiplayer.ClientLevel -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.BlendMode import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelColors import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelSampling import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelSpec @@ -59,7 +58,6 @@ class TimeOfDayPropertyType : PropertyType { graphValueFormatter = TimeOfDayValueFormatter, ), ) - override val blendModes = setOf(BlendMode.OVERRIDE, BlendMode.ADD, BlendMode.SUBTRACT) override fun decompose(value: Float, into: FloatArray) { into[0] = value @@ -84,7 +82,6 @@ class WeatherPropertyType : PropertyType { }, ), ) - override val blendModes = setOf(BlendMode.OVERRIDE) override val isChannelSpaceLinear = false override fun decompose(value: CutsceneWeather, into: FloatArray) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt index 9aa055d67..e7e05cf7b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/cutscene/CutsceneMigrations.kt @@ -3,27 +3,64 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.minecraft.nbt.Tag +import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f import ru.hollowhorizon.hollowengine.common.utils.nbt.NBTFormat import ru.hollowhorizon.hollowengine.common.utils.serialization.deserialize /** - * Reads cutscenes written before properties, layers and per-channel curves existed. + * Reads cutscenes written before the shape they have now. * * Version 1 stored one keyframe per *vector*, in absolute world coordinates, with a named easing. - * Version 2 added an origin and Bezier handles but kept the vector keys. Both are converted to the - * current shape: coordinates end up relative to an origin, and every vector key becomes one key per channel. + * Version 2 added an origin and Bezier handles but kept the vector keys. + * Version 3 had properties whose curves lived inside a stack of blended layers. + * All of them end up in the current shape: coordinates relative to an origin, one key per channel, and curves directly on the property. */ object CutsceneMigrations { - const val CURRENT_VERSION = 3 + const val CURRENT_VERSION = 4 - fun read(payload: Tag, version: Int): CutsceneData { - if (version >= CURRENT_VERSION) return NBTFormat.deserialize(payload) - val legacy: LegacyCutsceneData = NBTFormat.deserialize(payload) - return convert(legacy, version) + fun read(payload: Tag, version: Int): CutsceneData = when { + version >= CURRENT_VERSION -> NBTFormat.deserialize(payload) + version == 3 -> flattenLayers(NBTFormat.deserialize(payload)) + else -> convert(NBTFormat.deserialize(payload), version) } + /** + * Drops the layer stack of a version 3 cutscene, keeping the first layer that did anything. + */ + private fun flattenLayers(data: LayeredCutsceneData): CutsceneData = CutsceneData( + name = data.name, + duration = data.duration, + origin = data.origin, + nodes = data.nodes.map { it.flatten() }, + ) + + private fun LayeredNodeData.flatten(): CutsceneNodeData = CutsceneNodeData( + id = id, + name = name, + kind = kind, + children = children.map { it.flatten() }, + property = property?.let { stored -> + val layer = stored.layers.firstOrNull { it.visible && it.weight != 0f } ?: stored.layers.firstOrNull() + if (stored.layers.size > 1) { + HollowEngine.LOGGER.warn( + "Cutscene property '{}' had {} layers; keeping '{}' and dropping the rest", + name, + stored.layers.size, + layer?.name, + ) + } + CutscenePropertyData( + type = stored.type, + rotationMode = stored.rotationMode, + visible = layer?.visible ?: true, + locked = layer?.locked ?: false, + curves = layer?.curves.orEmpty(), + ) + }, + ) + internal fun convert(legacy: LegacyCutsceneData, version: Int): CutsceneData = legacy.toCutscene(version) private fun LegacyCutsceneData.toCutscene(version: Int): CutsceneData { @@ -152,10 +189,7 @@ object CutsceneMigrations { id = id, name = name, kind = CutsceneNodeKind.PROPERTY, - property = CutscenePropertyData( - type = type, - layers = listOf(CutsceneLayerData(name = "Base", curves = curves)), - ), + property = CutscenePropertyData(type = type, curves = curves), ) private fun Vec3f.component(index: Int): Float = when (index) { @@ -202,6 +236,41 @@ private object LegacyEasings { fun presetFor(easing: String): CurvePreset? = CurvePresets.byId(presets[easing] ?: "smooth") } +/** Version 3: properties whose curves lived inside a stack of blended layers. */ +@Serializable +internal data class LayeredLayerData( + val name: String, + val blend: String = "override", + val weight: Float = 1f, + val visible: Boolean = true, + val locked: Boolean = false, + val curves: List = emptyList(), +) + +@Serializable +internal data class LayeredPropertyData( + val type: String, + val rotationMode: String = CutsceneEnums.DEFAULT_ROTATION_MODE, + val layers: List = emptyList(), +) + +@Serializable +internal data class LayeredNodeData( + val id: String, + val name: String, + val kind: CutsceneNodeKind, + val children: List = emptyList(), + val property: LayeredPropertyData? = null, +) + +@Serializable +internal data class LayeredCutsceneData( + val name: String = "New Cutscene", + val duration: Float = 10f, + val origin: CutsceneOrigin = CutsceneOrigin(), + val nodes: List = emptyList(), +) + @Serializable internal data class LegacyAnchorData( val originX: Float = 0f, 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/CutsceneLang.kt index 1d7d30efd..9171608f6 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/CutsceneLang.kt @@ -9,6 +9,7 @@ internal object CutsceneLang { const val VIEW_DOPE_SHEET = ROOT + "view.dope_sheet" const val VIEW_CURVES = ROOT + "view.curves" const val FRAME_CURVES = ROOT + "view.frame_curves" + const val FRAME_TIME = ROOT + "view.frame_time" const val CAPTURE_KEYFRAME = ROOT + "action.capture" const val ADD_KEYFRAME = ROOT + "action.add_keyframe" @@ -24,6 +25,14 @@ internal object CutsceneLang { const val LOAD = ROOT + "action.load" const val CANCEL = ROOT + "action.cancel" const val CLOSE = ROOT + "action.close" + const val TO_START = ROOT + "action.to_start" + const val TO_END = ROOT + "action.to_end" + const val PLAY = ROOT + "action.play" + const val PREV_KEY = ROOT + "action.prev_key" + const val NEXT_KEY = ROOT + "action.next_key" + const val ZOOM_IN = ROOT + "action.zoom_in" + 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" @@ -44,6 +53,7 @@ internal object CutsceneLang { 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" const val ORIGIN_X = ROOT + "origin.x" const val ORIGIN_Y = ROOT + "origin.y" @@ -71,23 +81,14 @@ internal object CutsceneLang { const val HANDLE_IN = ROOT + "handles.incoming" const val HANDLE_OUT = ROOT + "handles.outgoing" - const val LAYER_SETTINGS = ROOT + "layer.settings" - const val PROPERTY_SETTINGS = ROOT + "property.settings" - const val LAYER_WEIGHT = ROOT + "layer.weight" - const val LAYER_VISIBLE = ROOT + "layer.visible" - const val LAYER_LOCKED = ROOT + "layer.locked" - const val LAYER_DELETE = ROOT + "layer.delete" - const val BLEND_MODE = ROOT + "layer.blend_mode" - const val BLEND_OVERRIDE = ROOT + "blend.override" - const val BLEND_ADD = ROOT + "blend.add" - const val BLEND_SUBTRACT = ROOT + "blend.subtract" - const val BLEND_MULTIPLY = ROOT + "blend.multiply" + const val TRACK_SELECT_KEYS = ROOT + "track.select_keys" + const val TRACK_SHOW = ROOT + "track.show" + const val TRACK_HIDE = ROOT + "track.hide" + const val TRACK_LOCK = ROOT + "track.lock" + const val TRACK_UNLOCK = ROOT + "track.unlock" + const val TRACK_REMOVE = ROOT + "track.remove" const val ROTATION_MODE = ROOT + "rotation.mode" const val ROTATION_EULER = ROOT + "rotation.euler" const val ROTATION_QUATERNION = ROOT + "rotation.quaternion" - const val ROTATION_MODE_HINT = ROOT + "rotation.mode_hint" - - const val LIMITS = ROOT + "limits.title" - const val LIMITS_HINT = ROOT + "limits.hint" } 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 0b28f201c..18d15277b 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 @@ -3,27 +3,65 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui import androidx.compose.runtime.* import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* +import ru.hollowhorizon.hollowengine.client.ui.docking.DockTags +import ru.hollowhorizon.hollowengine.client.ui.docking.LocalDockPanelTitle import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* +import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow -import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdown +import ru.hollowhorizon.hollowengine.client.ui.widgets.ContextMenu import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextInputFilter import ru.hollowhorizon.hollowengine.client.ui.widgets.tooltipOnHover import ru.hollowhorizon.hollowengine.client.utils.lang +import kotlin.math.abs + +/** The stylesheet of the timeline window's chrome; lanes and keys are drawn from [TimelineColors]. */ +internal const val TimelineStylesheet = "hollowengine:ui/styles/timeline.hss" + +/** The icons of the timeline, one family drawn for it. */ +internal object TimelineIcons { + private const val ROOT = "hollowengine:textures/gui/icons/timeline/" + + const val PLAY = ROOT + "play.svg" + const val PAUSE = ROOT + "pause.svg" + const val TO_START = ROOT + "to_start.svg" + const val TO_END = ROOT + "to_end.svg" + const val PREV_KEY = ROOT + "prev_key.svg" + const val NEXT_KEY = ROOT + "next_key.svg" + const val ZOOM_IN = ROOT + "zoom_in.svg" + const val ZOOM_OUT = ROOT + "zoom_out.svg" + const val MORE = ROOT + "more.svg" + const val DOPE_SHEET = ROOT + "dope_sheet.svg" + const val CURVES = ROOT + "curves.svg" + const val FRAME = ROOT + "frame.svg" + const val CAPTURE = ROOT + "capture.svg" + const val EYE = ROOT + "eye.svg" + const val EYE_OFF = ROOT + "eye_off.svg" + const val LOCK = ROOT + "lock.svg" + const val LOCK_OPEN = ROOT + "lock_open.svg" + const val CLOSE = ROOT + "close.svg" + const val DELETE = ROOT + "delete.svg" + const val SMOOTH = ROOT + "smooth.svg" + const val FILM = ROOT + "film.svg" + const val SAVE = ROOT + "save.svg" + const val LOAD = ROOT + "load.svg" +} -private const val PlayIcon = "hollowengine:textures/gui/icons/play.svg" -private const val PauseIcon = "hollowengine:textures/gui/icons/pause.svg" -private const val StartIcon = "hollowengine:textures/gui/icons/step_backward.svg" -private const val EndIcon = "hollowengine:textures/gui/icons/step_forward.svg" -private const val ZoomInIcon = "hollowengine:textures/gui/icons/zoom_in.svg" -private const val ZoomOutIcon = "hollowengine:textures/gui/icons/zoom_out.svg" -private const val MenuIcon = "hollowengine:textures/gui/icons/options.svg" -private const val DopeSheetIcon = "hollowengine:textures/gui/icons/layers.svg" -private const val CurvesIcon = "hollowengine:textures/gui/icons/graph.svg" -private const val FrameCurvesIcon = "hollowengine:textures/gui/icons/maximize.svg" -private const val PulseIcon = "hollowengine:textures/gui/icons/pulse.svg" -private const val AddIcon = "hollowengine:textures/gui/icons/add.svg" -private const val SettingsIcon = "hollowengine:textures/gui/icons/general.svg" +/** + * Which of the shared timeline controls make sense for what is being edited. + */ +class TimelineFeatures( + val capture: Boolean = true, + val storage: Boolean = true, + val cameraPreview: Boolean = true, + /** Play, pause and the jumps to either end; a curve with no clock has no use for them. */ + val playback: Boolean = true, +) { + companion object { + val CUTSCENE = TimelineFeatures() + } +} @Composable internal fun TimelineToolbar( @@ -32,168 +70,197 @@ internal fun TimelineToolbar( onSave: () -> Unit, onLoad: () -> Unit, refresh: () -> Unit, + features: TimelineFeatures = TimelineFeatures.CUTSCENE, ) { var menuOpen by remember { mutableStateOf(false) } + var menuAnchor by remember { mutableStateOf(UiRect.Zero) } + val parked = LocalDockPanelTitle.current Row( - id = "cutscene-timeline-toolbar", - modifier = Modifier.size(100.percent, 34.px).alignItems(vertical = UiAlign.CENTER) - .background(TimelineColors.Panel).border(1.px, TimelineColors.Border).padding(6.px, 0.px).gap(4.px), + id = parked?.headerId ?: "cutscene-timeline-toolbar", + tags = listOf("timeline-toolbar"), + modifier = parked?.dragHandle, ) { - ToolbarIcon(StartIcon, "timeline-start") { - controller.isPlaying = false - controller.applyCurrentTime(0f) - refresh() + parked?.let { panel -> + panel.icon?.let { icon -> Image(icon, tags = listOf(DockTags.PinnedHeaderIcon)) } + Text(panel.title, tags = listOf(DockTags.PinnedHeaderLabel), modifier = Modifier.textWrap(false)) + TimelineSeparator() } - ToolbarIcon( - if (controller.isPlaying) PauseIcon else PlayIcon, - "timeline-play", - active = controller.isPlaying, - ) { - controller.togglePlayback() - refresh() - } - ToolbarIcon(EndIcon, "timeline-end") { - controller.isPlaying = false - controller.applyCurrentTime(controller.workAreaEnd) - refresh() + if (features.playback) { + TimelineButton(TimelineIcons.TO_START, "timeline-start", CutsceneLang.TO_START.lang) { + controller.isPlaying = false + controller.applyCurrentTime(0f) + refresh() + } + TimelineButton(TimelineIcons.PREV_KEY, "timeline-prev-key", CutsceneLang.PREV_KEY.lang) { + if (controller.jumpToKey(-1)) refresh() + } + TimelineButton( + if (controller.isPlaying) TimelineIcons.PAUSE else TimelineIcons.PLAY, + "timeline-play", + CutsceneLang.PLAY.lang, + active = controller.isPlaying, + ) { + controller.togglePlayback() + refresh() + } + TimelineButton(TimelineIcons.NEXT_KEY, "timeline-next-key", CutsceneLang.NEXT_KEY.lang) { + if (controller.jumpToKey(1)) refresh() + } + TimelineButton(TimelineIcons.TO_END, "timeline-end", CutsceneLang.TO_END.lang) { + controller.isPlaying = false + controller.applyCurrentTime(controller.workAreaEnd) + refresh() + } + TimeField(controller, refresh) + TimelineSeparator() } - TimelineSeparator() - - ToolbarIcon( - DopeSheetIcon, + TimelineButton( + TimelineIcons.DOPE_SHEET, "timeline-view-dope", + CutsceneLang.VIEW_DOPE_SHEET.lang, active = controller.viewMode == TimelineViewMode.DOPE_SHEET, - tooltip = CutsceneLang.VIEW_DOPE_SHEET.lang, ) { controller.viewMode = TimelineViewMode.DOPE_SHEET refresh() } - ToolbarIcon( - CurvesIcon, + TimelineButton( + TimelineIcons.CURVES, "timeline-view-curves", + CutsceneLang.VIEW_CURVES.lang, active = controller.viewMode == TimelineViewMode.CURVES, - tooltip = CutsceneLang.VIEW_CURVES.lang, ) { controller.enterCurveView() refresh() } - TimelineSeparator() - - Text( - "${formatSeconds(controller.currentTime)} / ${formatSeconds(controller.workAreaEnd)}", - modifier = Modifier.align(vertical = UiAlign.CENTER).fontSize(11f).foreground(TimelineColors.Text), - ) - // Push the trailing controls to the right edge. Box(modifier = Modifier.size(0.px, 1.px).grow(1f)) - ToolbarIcon(PulseIcon, "timeline-capture", tooltip = CutsceneLang.CAPTURE_KEYFRAME.lang) { - onCapture() - refresh() - } - - TimelineSeparator() - - if (controller.viewMode == TimelineViewMode.CURVES) { - ToolbarIcon(FrameCurvesIcon, "timeline-frame-curves", tooltip = CutsceneLang.FRAME_CURVES.lang) { - controller.frameCurves() + if (features.capture) { + TimelineButton(TimelineIcons.CAPTURE, "timeline-capture", CutsceneLang.CAPTURE_KEYFRAME.lang) { + onCapture() refresh() } + TimelineSeparator() } - ToolbarIcon(ZoomOutIcon, "timeline-zoom-out") { + val curves = controller.viewMode == TimelineViewMode.CURVES + TimelineButton( + TimelineIcons.FRAME, + "timeline-frame", + (if (curves) CutsceneLang.FRAME_CURVES else CutsceneLang.FRAME_TIME).lang, + ) { + if (curves) controller.frameCurves() + controller.requestFrameTime() + refresh() + } + TimelineButton(TimelineIcons.ZOOM_OUT, "timeline-zoom-out", CutsceneLang.ZOOM_OUT.lang) { zoomAroundCenter(controller, 1f / TimelineZoomButtonFactor) refresh() } - ToolbarIcon(ZoomInIcon, "timeline-zoom-in") { + TimelineButton(TimelineIcons.ZOOM_IN, "timeline-zoom-in", CutsceneLang.ZOOM_IN.lang) { zoomAroundCenter(controller, TimelineZoomButtonFactor) refresh() } - TimelineSeparator() - // Everything that doesn't fit on the bar lives in an overflow menu. - UiDropdown( - id = "timeline-overflow", - label = "", - icon = MenuIcon, - expanded = menuOpen, - onExpandedChange = { menuOpen = it }, - tags = listOf("timeline-overflow"), - items = listOf( - UiDropdownItem(CutsceneLang.CAPTURE_KEYFRAME.lang, icon = PulseIcon) { - onCapture(); refresh() - }, + Box(modifier = Modifier.onPlaced { menuAnchor = it }) { + TimelineButton(TimelineIcons.MORE, "timeline-overflow", CutsceneLang.MORE.lang, active = menuOpen) { + menuOpen = !menuOpen + } + } + if (menuOpen) ContextMenu( + id = "timeline-overflow-menu", + anchorBounds = menuAnchor, + alignment = OverflowMenuAlignment, + onExpandedChange = { if (!it) menuOpen = false }, + items = listOfNotNull( UiDropdownItem( CutsceneLang.DELETE_SELECTED.lang, - icon = "hollowengine:textures/gui/icons/remove.svg", + icon = TimelineIcons.DELETE, enabled = controller.selectedKeyframes.isNotEmpty(), + shortcut = "Del", ) { controller.deleteSelectedKeyframes(); refresh() }, UiDropdownItem( CutsceneLang.SMOOTH_SELECTED.lang, - icon = CurvesIcon, + icon = TimelineIcons.SMOOTH, enabled = controller.canEditSelectedCurves, + shortcut = "S", ) { controller.smoothSelectedKeyframes(); refresh() }, - UiDropdownItem( + if (!features.cameraPreview) null else UiDropdownItem( if (controller.isCameraPreviewEnabled) CutsceneLang.CAMERA_PREVIEW_ON.lang else CutsceneLang.CAMERA_PREVIEW_OFF.lang, - icon = "hollowengine:textures/gui/icons/film.svg", + icon = TimelineIcons.FILM, closeOnClick = false, + separatorBefore = true, ) { controller.applyCameraPreviewEnabled(!controller.isCameraPreviewEnabled) refresh() }, - UiDropdownItem(CutsceneLang.SAVE.lang, icon = "hollowengine:textures/gui/icons/save.svg") { onSave() }, - UiDropdownItem(CutsceneLang.LOAD.lang, icon = "hollowengine:textures/gui/icons/load.svg") { onLoad() }, + if (!features.storage) null else UiDropdownItem( + CutsceneLang.SAVE.lang, + icon = TimelineIcons.SAVE, + separatorBefore = true, + ) { onSave() }, + if (!features.storage) null else UiDropdownItem( + CutsceneLang.LOAD.lang, + icon = TimelineIcons.LOAD, + ) { onLoad() }, ), ) } } +/** Where the playhead is, as a field: typing a time jumps there. */ @Composable -private fun ToolbarIcon( +private fun TimeField(controller: TimelineController, refresh: () -> Unit) { + TextField( + value = formatSeconds(controller.currentTime), + id = "timeline-time", + fontSize = 9f, + filter = UiTextInputFilter.DECIMAL, + tags = listOf("timeline-time"), + modifier = Modifier.tooltipOnHover(CutsceneLang.TIME_HINT.lang), + onChange = { typed -> + val time = typed.toFloatOrNull() ?: return@TextField + if (abs(time - controller.currentTime) < TimeFieldEpsilon) return@TextField + controller.isPlaying = false + controller.applyCurrentTime(time) + refresh() + }, + ) + Text("/ " + formatSeconds(controller.workAreaEnd), tags = listOf("timeline-duration")) +} + +private const val TimeFieldEpsilon = 0.005f + +/** A flat button of the bar, the way the IDE toolbar draws them. */ +@Composable +private fun TimelineButton( icon: String, id: String, + tooltip: String, active: Boolean = false, - tooltip: String? = null, onClick: () -> Unit, ) { - var hovered by remember { mutableStateOf(false) } - val background = when { - active -> TimelineAccentSoft - hovered -> TimelineColors.PanelAlt - else -> UiColor.Transparent - } Box( id = id, - mode = UiBoxMode.STACK, - modifier = Modifier.size(24.px, 24.px).background(background) - .border(1.px, if (active) TimelineColors.Accent else TimelineColors.Border, 4f).cursor(UiCursorShape.HAND) - .let { if (tooltip == null) it else it.tooltipOnHover(tooltip) }.onEnter { hovered = true } - .onExit { hovered = false }.onClick { event -> - onClick() - event.consume() - }, + tags = if (active) listOf("timeline-button", "active") else listOf("timeline-button"), + modifier = Modifier.cursor(UiCursorShape.HAND).tooltipOnHover(tooltip).onClick { event -> + onClick() + event.consume() + }, ) { - key(icon) { - Image( - icon, - modifier = Modifier.size(14.px, 14.px).align(UiAlign.CENTER, UiAlign.CENTER) - .opacity(if (active || hovered) 1f else 0.8f), - ) - } + key(icon) { Image(icon, tags = listOf("timeline-button-icon")) } } } -private val TimelineAccentSoft = UiColor(1f, 0.54f, 0.18f, 0.22f) - @Composable internal fun TimelineHeaders( controller: TimelineController, @@ -202,39 +269,38 @@ internal fun TimelineHeaders( verticalOffset: Float, ownsVerticalScroll: Boolean, contentHeight: Float, - onLayerSettings: (AnimLayer) -> Unit, - onPropertySettings: (AnimProperty<*>) -> Unit, + trackScroll: UiScrollHandle, refresh: () -> Unit, ) { val width = controller.headerWidth val labelWidth = maxOf(width, rows.maxOfOrNull { headerLabelWidth(it) } ?: width) + var menu by remember { mutableStateOf(null) } + Column( id = "timeline-headers", - modifier = Modifier.size(width.px, 100.percent).background(TimelineColors.Panel), + modifier = Modifier.size(width.px, 100.percent), ) { // Corner cell, level with the ruler so the rows below line up with the lanes. Box( mode = UiBoxMode.STACK, - modifier = Modifier.size(width.px, TimelineRulerHeight.px).background(TimelineColors.Group), + tags = listOf("timeline-corner"), + modifier = Modifier.size(width.px, TimelineRulerHeight.px), ) { Text( CutsceneLang.TRACKS.lang, - modifier = Modifier.align(vertical = UiAlign.CENTER).margin(12.px, 0.px, 0.px, 0.px).fontSize(10f) - .foreground(TimelineColors.Muted), - ) - Box( - modifier = Modifier.position(0.px, (TimelineRulerHeight - 1f).px).size(width.px, 1.px) - .background(TimelineColors.Border), + tags = listOf("timeline-corner-label"), + modifier = Modifier.align(vertical = UiAlign.CENTER), ) } val overflows = labelWidth > width + 1f Box( + tags = listOf("timeline-scroll"), modifier = Modifier.size(width.px, 0.px).grow(1f).clip().scrollable( - state = scroll, - vertical = ownsVerticalScroll, - horizontal = overflows, - hasHorizontalScrollbar = overflows, - ), + state = scroll, + vertical = ownsVerticalScroll, + horizontal = overflows, + hasHorizontalScrollbar = overflows, + ).onScroll { event -> scrollTrackList(event, trackScroll, scroll) }, ) { Box( modifier = Modifier.position((-scroll.offsetX).px, (-verticalOffset).px) @@ -242,236 +308,264 @@ internal fun TimelineHeaders( ) { rows.forEach { row -> key(row.id) { - TimelineHeaderRow(row, controller, labelWidth, onLayerSettings, onPropertySettings, refresh) + TimelineHeaderRow(row, controller, labelWidth, refresh) { event -> + menu = TrackMenu(event.x, event.y, row) + } } } } } } + + menu?.let { open -> + ContextMenu( + id = "timeline-track-menu", + anchorBounds = UiRect(open.x, open.y, 0f, 0f), + alignment = UiPopupAlignment.Cursor, + items = trackMenu(controller, open.row, refresh), + onExpandedChange = { if (!it) menu = null }, + ) + } } +/** Room at the end of a track row, so the list's scrollbar does not sit on the row's buttons. */ +private const val TrackRowEndPadding = 9f + +private class TrackMenu(val x: Float, val y: Float, val row: TimelineRow) + private fun headerLabelWidth(row: TimelineRow): Float = - 8f + row.depth * 12f + row.label.length * 5.5f + headerControlsWidth(row) + 8f + row.depth * 10f + row.label.length * 5.2f + headerControlsWidth(row) private fun headerControlsWidth(row: TimelineRow): Float = when (row.kind) { - TimelineRowKind.CHANNEL -> 40f - TimelineRowKind.LAYER -> 76f + TimelineRowKind.CHANNEL -> 20f else -> 58f } -private const val EyeOnIcon = "hollowengine:textures/gui/icons/visible.svg" -private const val EyeOffIcon = "hollowengine:textures/gui/icons/invisible.svg" -private const val LockedIcon = "hollowengine:textures/gui/icons/locked.svg" -private const val UnlockedIcon = "hollowengine:textures/gui/icons/unlocked.svg" -private const val CollapseArrowIcon = "hollowengine:textures/gui/icons/arrow.svg" - @Composable private fun TimelineHeaderRow( row: TimelineRow, controller: TimelineController, width: Float, - onLayerSettings: (AnimLayer) -> Unit, - onPropertySettings: (AnimProperty<*>) -> Unit, refresh: () -> Unit, + onMenu: (UiEvent) -> Unit, ) { val top = row.y - TimelineRulerHeight - val isActiveLayer = row.kind == TimelineRowKind.LAYER && controller.activeLayer === row.layer 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 color = when { - row.kind == TimelineRowKind.GROUP -> TimelineColors.Group - isFocused -> TimelineColors.Accent.withAlpha(0.26f) - isActiveLayer -> TimelineColors.Blue.withAlpha(0.28f) - row.locked -> UiColor(0.09f, 0.09f, 0.1f, 1f) - !row.visible -> UiColor(0.09f, 0.1f, 0.11f, 1f) - row.kind == TimelineRowKind.CHANNEL -> TimelineColors.PanelAlt - else -> TimelineColors.Panel + val tags = buildList { + add("timeline-row") + add(row.kind.name.lowercase()) + if (isFocused) add("focused") + if (row.locked) add("locked") + if (!row.visible) add("muted") } Box( id = "header-${row.id}", mode = UiBoxMode.STACK, - modifier = Modifier.position(0.px, top.px).size(width.px, row.height.px).background(color).onClick { event -> - // 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()) { - controller.focusCurves(rowCurves, additive = event.modifiers and GLFW.GLFW_MOD_CONTROL != 0) - } - when { - row.group != null -> row.group.isCollapsed = !row.group.isCollapsed - row.kind == TimelineRowKind.LAYER -> controller.activeLayer = row.layer - else -> if (!isCurveView) controller.clearSelection() - } + tags = tags, + modifier = Modifier.position(0.px, top.px).size(width.px, row.height.px).onClick { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + onMenu(event) 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()) { + controller.focusCurves(rowCurves, additive = event.modifiers and GLFW.GLFW_MOD_CONTROL != 0) + } + when { + row.group != null -> row.group.isCollapsed = !row.group.isCollapsed + else -> if (!isCurveView) controller.clearSelection() + } + event.consume() + refresh() + }, ) { Row( modifier = Modifier.size(100.percent, 100.percent).alignItems(vertical = UiAlign.CENTER) - .padding(0.px, 0.px, 6.px, 0.px).gap(2.px), + .padding(0.px, 0.px, TrackRowEndPadding.px, 0.px).gap(2.px), ) { - Box(modifier = Modifier.size((8f + row.depth * 12f).px, 1.px)) + Box(modifier = Modifier.size((4f + row.depth * 10f).px, 1.px)) when { - row.group != null -> Image( - CollapseArrowIcon, - modifier = Modifier.size(10.px, 10.px).align(vertical = UiAlign.CENTER) - .rotate(z = if (row.group.isCollapsed) 0f else 90f).opacity(0.75f) - .margin(2.px, 0.px, 4.px, 0.px), - ) - - row.kind == TimelineRowKind.PROPERTY -> Expander(row.property?.isExpanded == true) { - row.property?.let { it.isExpanded = !it.isExpanded } + row.group != null -> DisclosureArrow("${row.id}-fold", !row.group.isCollapsed) { + row.group.isCollapsed = !row.group.isCollapsed refresh() } - row.kind == TimelineRowKind.LAYER -> Expander(row.layer?.isExpanded == true) { - row.layer?.let { it.isExpanded = !it.isExpanded } - refresh() - } + row.kind == TimelineRowKind.PROPERTY && (row.property?.curves?.size ?: 0) > 1 -> + DisclosureArrow("${row.id}-fold", row.property?.isExpanded == true) { + row.property?.let { it.isExpanded = !it.isExpanded } + refresh() + } + + else -> Box(modifier = Modifier.size(10.px, 1.px)) } row.color?.let { swatch -> Box( - modifier = Modifier.size(3.px, 12.px).align(vertical = UiAlign.CENTER) + modifier = Modifier.size(3.px, 10.px).align(vertical = UiAlign.CENTER) .background(swatch.toUiColor(if (row.visible) 1f else 0.35f)).borderRadius(1.5f) - .margin(0.px, 0.px, 6.px, 0.px), + .margin(0.px, 0.px, 3.px, 0.px), ) } Text( row.label, - modifier = Modifier.grow(1f).align(vertical = UiAlign.CENTER) - .fontSize(if (row.kind == TimelineRowKind.CHANNEL) 10f else 11f) - .foreground(if (row.visible) TimelineColors.Text else TimelineColors.Muted).textWrap(false), + tags = listOf("timeline-row-label"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f).align(vertical = UiAlign.CENTER) + .textOverflow(UiTextOverflow.DOTS), ) - row.layer?.takeIf { row.kind == TimelineRowKind.LAYER && it.blendMode != BlendMode.OVERRIDE } - ?.let { layer -> - Text( - blendModeLabel(layer.blendMode), - modifier = Modifier.align(vertical = UiAlign.CENTER).fontSize(9f) - .foreground(TimelineColors.Accent).textWrap(false).margin(0.px, 0.px, 4.px, 0.px), - ) - } - HeaderControls(row, controller, onLayerSettings, onPropertySettings, refresh) + HeaderControls(row, controller, refresh) } - Box( - modifier = Modifier.position(0.px, (row.height - 1f).px).size(width.px, 1.px) - .background(TimelineColors.Border), - ) } } @Composable -private fun HeaderControls( - row: TimelineRow, - controller: TimelineController, - onLayerSettings: (AnimLayer) -> Unit, - onPropertySettings: (AnimProperty<*>) -> Unit, - refresh: () -> Unit, -) { +private fun HeaderControls(row: TimelineRow, controller: TimelineController, refresh: () -> Unit) { + val hideProperty = controller.onHideProperty + val hideGroup = controller.onHideGroup + val property = row.property.takeIf { row.kind == TimelineRowKind.PROPERTY } + val group = row.group + + if (property != null && hideProperty != null || group != null && hideGroup != null) { + RowToggle("${row.id}-remove", TimelineIcons.CLOSE, CutsceneLang.TRACK_REMOVE.lang, listOf("hide-track")) { + if (property != null) hideProperty?.invoke(property) else group?.let { hideGroup?.invoke(it) } + refresh() + } + } + when (row.kind) { TimelineRowKind.CHANNEL -> { val curve = row.curve ?: return - HeaderIconToggle(if (curve.isVisible) EyeOnIcon else EyeOffIcon, curve.isVisible, accent = false) { + VisibilityToggle("${row.id}-visible", curve.isVisible) { curve.isVisible = !curve.isVisible refresh() } } - TimelineRowKind.LAYER -> { - val layer = row.layer ?: return - HeaderIconToggle(SettingsIcon, active = false, accent = false) { onLayerSettings(layer) } - HeaderIconToggle(if (layer.isVisible) EyeOnIcon else EyeOffIcon, layer.isVisible, accent = false) { - layer.isVisible = !layer.isVisible - refresh() - } - HeaderIconToggle(if (layer.isLocked) LockedIcon else UnlockedIcon, layer.isLocked, accent = true) { - layer.isLocked = !layer.isLocked - refresh() - } - } - TimelineRowKind.PROPERTY -> { - val property = row.property ?: return - HeaderIconToggle(SettingsIcon, active = false, accent = false) { onPropertySettings(property) } - HeaderIconToggle(AddIcon, active = false, accent = false) { - val layer = property.addLayer(blendMode = BlendMode.ADD) - property.isExpanded = true - controller.activeLayer = layer - onLayerSettings(layer) + val owner = row.property ?: return + VisibilityToggle("${row.id}-visible", owner.isVisible) { + owner.isVisible = !owner.isVisible refresh() } - val visible = property.layers.any { it.isVisible } - HeaderIconToggle(if (visible) EyeOnIcon else EyeOffIcon, visible, accent = false) { - property.layers.forEach { it.isVisible = !visible } + LockToggle("${row.id}-lock", owner.isLocked) { + owner.isLocked = !owner.isLocked refresh() } } TimelineRowKind.GROUP -> { - val group = row.group ?: return - val visible = groupLayers(group).any { it.isVisible } - HeaderIconToggle(if (visible) EyeOnIcon else EyeOffIcon, visible, accent = false) { - groupLayers(group).forEach { it.isVisible = !visible } - group.isVisible = !visible + val owner = row.group ?: return + VisibilityToggle("${row.id}-visible", owner.isVisible) { + owner.isVisible = !owner.isVisible refresh() } - HeaderIconToggle(if (group.isLocked) LockedIcon else UnlockedIcon, group.isLocked, accent = true) { - group.isLocked = !group.isLocked + LockToggle("${row.id}-lock", owner.isLocked) { + owner.isLocked = !owner.isLocked refresh() } } } } -private fun groupLayers(group: TrackGroup): List = group.allProperties().flatMap { it.layers } +@Composable +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) listOf("on") else emptyList(), + onClick, +) + +@Composable +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) listOf("warn") else emptyList(), + onClick, +) @Composable -private fun Expander(expanded: Boolean, onClick: () -> Unit) { +private fun RowToggle(id: String, icon: String, tooltip: String, states: List, onClick: () -> Unit) { Box( - mode = UiBoxMode.STACK, - modifier = Modifier.size(12.px, 12.px).align(vertical = UiAlign.CENTER).cursor(UiCursorShape.HAND) - .margin(0.px, 0.px, 2.px, 0.px).onClick { + id = id, + tags = listOf("timeline-row-toggle") + states, + modifier = Modifier.align(vertical = UiAlign.CENTER).cursor(UiCursorShape.HAND).tooltipOnHover(tooltip) + .onClick { onClick() it.consume() }, ) { - Image( - CollapseArrowIcon, - modifier = Modifier.size(9.px, 9.px).align(UiAlign.CENTER, UiAlign.CENTER) - .rotate(z = if (expanded) 90f else 0f).opacity(0.6f), - ) + key(icon) { Image(icon, tags = listOf("timeline-row-icon")) } } } @Composable -private fun HeaderIconToggle(icon: String, active: Boolean, accent: Boolean, onClick: () -> Unit) { - var hovered by remember { mutableStateOf(false) } - val tint = when { - active && accent -> TimelineColors.Accent - active -> TimelineColors.Text - else -> TimelineColors.Muted - } +private fun DisclosureArrow(id: String, expanded: Boolean, onClick: () -> Unit) { Box( - mode = UiBoxMode.STACK, - modifier = Modifier.size(18.px, 18.px).align(vertical = UiAlign.CENTER) - .background(if (hovered) TimelineColors.PanelAlt else UiColor.Transparent).borderRadius(3f) - .cursor(UiCursorShape.HAND).onEnter { hovered = true }.onExit { hovered = false }.onClick { + id = id, + tags = listOf("tree-expander"), + attributes = mapOf("expanded" to expanded.toString()), + modifier = Modifier.size(10.px, 10.px).margin(0.px).align(vertical = UiAlign.CENTER).cursor(UiCursorShape.HAND) + .onClick { onClick() it.consume() }, - ) { - Image( - icon, - modifier = Modifier.size(12.px, 12.px).align(UiAlign.CENTER, UiAlign.CENTER).tint(tint) - .opacity(if (active || hovered) 1f else 0.75f), - ) - } + ) } +/** + * What a right click on a track offers: its keys, whether it shows and takes edits, the basis a + * rotation is keyed in, and taking it off the list. + */ +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) + refresh() + }) + + val property = row.property.takeIf { row.kind == TimelineRowKind.PROPERTY } + val type = property?.type as? RotationPropertyType + if (property != null && type != null) { + add( + UiDropdownItem( + CutsceneLang.ROTATION_MODE.lang, + separatorBefore = true, + children = RotationMode.entries.map { mode -> + UiDropdownItem(rotationModeLabel(mode), checked = type.mode == mode) { + controller.edit("Change rotation basis") { property.setRotationMode(mode) } + refresh() + } + }, + ) + ) + } + + val hideProperty = controller.onHideProperty + val hideGroup = controller.onHideGroup + val group = row.group + if (property != null && hideProperty != null) { + add(UiDropdownItem(CutsceneLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { + hideProperty(property) + refresh() + }) + } else if (group != null && hideGroup != null) { + add(UiDropdownItem(CutsceneLang.TRACK_REMOVE.lang, icon = TimelineIcons.CLOSE, separatorBefore = true) { + hideGroup(group) + refresh() + }) + } + } + @Composable internal fun ToolbarButton(label: String, id: String, color: UiColor = TimelineColors.PanelAlt, onClick: () -> Unit) { Box( id = id, - modifier = Modifier.size(UiLength.Auto, 22.px).background(color).border(1.px, TimelineColors.Border, 3f) + modifier = Modifier.size(UiLength.Auto, 20.px).background(color).borderRadius(3f) .padding(10.px, 0.px).cursor(UiCursorShape.HAND).onClick { event -> onClick() event.consume() @@ -479,7 +573,7 @@ internal fun ToolbarButton(label: String, id: String, color: UiColor = TimelineC ) { Text( label, - modifier = Modifier.align(UiAlign.CENTER, UiAlign.CENTER).fontSize(10f).foreground(TimelineColors.Text) + modifier = Modifier.align(UiAlign.CENTER, UiAlign.CENTER).fontSize(9f).foreground(TimelineColors.Text) .textWrap(false).textOverflow(UiTextOverflow.DOTS).textAlign(UiTextAlign.CENTER), ) } @@ -487,21 +581,17 @@ internal fun ToolbarButton(label: String, id: String, color: UiColor = TimelineC @Composable private fun TimelineSeparator() { - Box(modifier = Modifier.size(1.px, 18.px).background(TimelineColors.Border)) -} - -internal fun blendModeLabel(mode: BlendMode): String = when (mode) { - BlendMode.OVERRIDE -> CutsceneLang.BLEND_OVERRIDE.lang - BlendMode.ADD -> CutsceneLang.BLEND_ADD.lang - BlendMode.SUBTRACT -> CutsceneLang.BLEND_SUBTRACT.lang - BlendMode.MULTIPLY -> CutsceneLang.BLEND_MULTIPLY.lang + Box(tags = listOf("timeline-separator")) } internal fun formatSeconds(value: Float): String = "%.2f".format(value).replace(',', '.') -/** Zoom keeping the visible centre roughly fixed; used by the toolbar buttons. */ +/** Zoom keeping the visible center roughly fixed; used by the toolbar buttons. */ internal fun zoomAroundCenter(controller: TimelineController, factor: Float) { controller.pixelsPerSecond = (controller.pixelsPerSecond * factor).coerceIn(TimelineMinZoom, TimelineMaxZoom) } internal const val TimelineZoomButtonFactor = 1.25f + +/** The overflow menu hangs under its button, flush with its right edge, since the button ends the bar. */ +private val OverflowMenuAlignment = UiPopupAlignment(anchorHorizontal = UiAlign.END, popupHorizontal = UiAlign.END) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineCurves.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineCurves.kt index d4da2dbfd..19267ef13 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineCurves.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineCurves.kt @@ -15,7 +15,7 @@ private const val CurvePointSize = 9f private const val CurveHandleSize = 7f internal data class CurveLane( - val layer: AnimLayer, + val property: AnimProperty<*>, val curve: ChannelCurve, val color: UiColor, val locked: Boolean, @@ -23,11 +23,10 @@ internal data class CurveLane( internal fun curveLanes(rows: List, controller: TimelineController): List { val lanes = rows.filter { it.kind == TimelineRowKind.PROPERTY && it.visible } - .mapNotNull { row -> row.property?.let { row to it } }.flatMap { (row, property) -> - property.layers.filter { it.isVisible }.flatMap { layer -> - layer.channels.filter { it.isVisible && it.spec.supportsCurveEditor }.map { curve -> - CurveLane(layer, curve, curve.color.toUiColor(), row.locked || layer.isLocked) - } + .mapNotNull { row -> row.property?.let { row to it } } + .flatMap { (row, property) -> + property.curves.filter { it.isVisible && it.spec.supportsCurveEditor }.map { curve -> + CurveLane(property, curve, curve.color.toUiColor(), row.locked) } } if (controller.focusedCurves.isEmpty()) return lanes @@ -125,6 +124,7 @@ internal fun TimelineCurveGraph( } } } + WorkAreaShade(controller, pxPerSec, contentWidth, height) MarqueeOverlay(marquee) CurveValueLabels(center, span, height, scrollX, commonValueFormatter(lanes)) Playhead(controller, pxPerSec, height) 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 2ad7fb839..adebcf75b 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 @@ -11,7 +11,7 @@ import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.utils.lang import kotlin.math.abs -private const val TimelineKeyframeSize = 13f +private const val TimelineKeyframeSize = 10f private const val LaneContentId = "timeline-lane-content" @Composable @@ -343,16 +343,24 @@ private fun KeyConnections(row: TimelineRow, pxPerSec: Float, keys: List= contentWidth) return Box( - modifier = Modifier.position(x.px, 0.px).size((contentWidth - x).px, rowsHeight.coerceAtLeast(1f).px) - .background(UiColor(0f, 0f, 0f, 0.38f)).inputTransparent(), + modifier = Modifier.position(x.px, 0.px).size((contentWidth - x).px, shadeHeight) + .background(OutsideWorkArea).inputTransparent(), ) } +private val OutsideWorkArea = UiColor(0f, 0f, 0f, 0.38f) + @Composable internal fun TimelineContextMenu( controller: TimelineController, @@ -390,7 +398,7 @@ internal fun TimelineContextMenu( add( UiDropdownItem( CutsceneLang.CURVE_PRESET.lang, - icon = GraphIcon, + icon = PresetIcon, enabled = hasCurveSelection, closeOnClick = false, ) { showPresets = true }) @@ -425,9 +433,10 @@ internal fun TimelineContextMenu( ) } -private const val PulseIcon = "hollowengine:textures/gui/icons/pulse.svg" -private const val GraphIcon = "hollowengine:textures/gui/icons/graph.svg" -private const val RemoveIcon = "hollowengine:textures/gui/icons/remove.svg" -private const val CopyIcon = "hollowengine:textures/gui/icons/copy.svg" -private const val CutIcon = "hollowengine:textures/gui/icons/cut.png" -private const val PasteIcon = "hollowengine:textures/gui/icons/paste.svg" +private const val PulseIcon = "hollowengine:textures/gui/icons/timeline/key_add.svg" +private const val GraphIcon = "hollowengine:textures/gui/icons/timeline/smooth.svg" +private const val PresetIcon = "hollowengine:textures/gui/icons/timeline/preset.svg" +private const val RemoveIcon = "hollowengine:textures/gui/icons/timeline/delete.svg" +private const val CopyIcon = "hollowengine:textures/gui/icons/timeline/copy.svg" +private const val CutIcon = "hollowengine:textures/gui/icons/timeline/cut.svg" +private const val PasteIcon = "hollowengine:textures/gui/icons/timeline/paste.svg" diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLayerDialog.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLayerDialog.kt deleted file mode 100644 index 90f622785..000000000 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/HollowTimelineLayerDialog.kt +++ /dev/null @@ -1,144 +0,0 @@ -package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui - -import androidx.compose.runtime.Composable -import ru.hollowhorizon.hollowengine.client.ui.inspector.* -import ru.hollowhorizon.hollowengine.client.ui.* -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.* -import ru.hollowhorizon.hollowengine.client.utils.lang - -@Composable -internal fun LayerSettingsDialog( - controller: TimelineController, - layer: AnimLayer, - refresh: () -> Unit, - onClose: () -> Unit, -) { - val property = controller.propertyOf(layer) ?: return onClose() - DialogFrame(CutsceneLang.LAYER_SETTINGS.lang, onClose) { - Text(property.nameState, modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted)) - TextField( - value = layer.nameState, - onChange = { layer.nameState = it; refresh() }, - 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), - ) - - Text(CutsceneLang.BLEND_MODE.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) - PillFlow(id = "layer-blend-modes") { - val modes = if (property.layers.firstOrNull() === layer) setOf(layer.blendMode) - else property.type.blendModes - modes.forEach { mode -> - Pill(blendModeLabel(mode), layer.blendMode == mode, id = "layer-blend-${mode.name}") { - controller.edit("Edit layer blending") { layer.blendMode = mode } - refresh() - } - } - } - - if (property.channels.any { it.supportsCurveEditor }) { - FloatRow(CutsceneLang.LAYER_WEIGHT.lang, layer.weight, min = 0f, max = 1f) { next -> - controller.edit("Edit layer weight") { layer.weight = next } - refresh() - } - } - - Row(modifier = Modifier.size(100.percent, 24.px).alignItems(vertical = UiAlign.CENTER).gap(6.px)) { - Pill(CutsceneLang.LAYER_VISIBLE.lang, layer.isVisible) { - layer.isVisible = !layer.isVisible - refresh() - } - Pill(CutsceneLang.LAYER_LOCKED.lang, layer.isLocked) { - layer.isLocked = !layer.isLocked - refresh() - } - } - - Row(modifier = Modifier.size(100.percent, UiLength.Auto).gap(8.px).align(horizontal = UiAlign.END)) { - if (property.layers.size > 1) { - ToolbarButton(CutsceneLang.LAYER_DELETE.lang, "layer-delete", TimelineColors.Danger) { - controller.edit("Delete layer") { - property.layers.remove(layer) - if (controller.activeLayer === layer) controller.activeLayer = property.layers.firstOrNull() - } - refresh() - onClose() - } - } - ToolbarButton(CutsceneLang.CLOSE.lang, "layer-close", TimelineColors.Blue) { onClose() } - } - } -} - -@Composable -internal fun PropertySettingsDialog( - controller: TimelineController, - property: AnimProperty<*>, - refresh: () -> Unit, - onClose: () -> Unit, -) { - DialogFrame(CutsceneLang.PROPERTY_SETTINGS.lang, onClose) { - Text(property.nameState, modifier = Modifier.fontSize(11f).foreground(TimelineColors.Text)) - - RotationModeRow(property, controller, refresh) - - val bounded = property.channels.indices.filter { property.bounds(it) != ChannelBounds.Unbounded } - if (bounded.isNotEmpty()) { - Text(CutsceneLang.LIMITS.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) - Text( - CutsceneLang.LIMITS_HINT.lang, - modifier = Modifier.size(100.percent, UiLength.Fit).fontSize(9f).foreground(TimelineColors.Muted), - ) - bounded.forEach { index -> - Row( - modifier = Modifier.size(100.percent, UiLength.Fit).gap(6.px).alignItems(vertical = UiAlign.CENTER), - ) { - Text( - property.channels[index].name, - modifier = Modifier.fontSize(10f).foreground(TimelineColors.Muted).textWrap(false), - ) - Text( - boundsLabel(property.bounds(index)), - modifier = Modifier.grow(1f).fontSize(10f).foreground(TimelineColors.Text).textWrap(false), - ) - } - } - } - - Row(modifier = Modifier.size(100.percent, UiLength.Auto).align(horizontal = UiAlign.END)) { - ToolbarButton(CutsceneLang.CLOSE.lang, "property-close", TimelineColors.Blue) { onClose() } - } - } -} - -private fun boundsLabel(bounds: ChannelBounds): String { - val low = bounds.minimum?.let { formatBound(it) } - val high = bounds.maximum?.let { formatBound(it) } - return when { - low != null && high != null -> "$low .. $high" - low != null -> ">= $low" - high != null -> "<= $high" - else -> "" - } -} - -private fun formatBound(value: Float): String = - if (value == value.toInt().toFloat()) value.toInt().toString() else "%.2f".format(value).replace(',', '.') - -@Composable -private fun RotationModeRow(property: AnimProperty<*>, controller: TimelineController, refresh: () -> Unit) { - val type = property.type as? RotationPropertyType ?: return - Text(CutsceneLang.ROTATION_MODE.lang, modifier = Modifier.fontSize(9f).foreground(TimelineColors.Muted)) - PillFlow(id = "property-rotation-modes") { - RotationMode.entries.forEach { mode -> - Pill(rotationModeLabel(mode), type.mode == mode, id = "rotation-mode-${mode.name}") { - controller.edit("Change rotation basis") { property.setRotationMode(mode) } - refresh() - } - } - } - Text( - CutsceneLang.ROTATION_MODE_HINT.lang, - modifier = Modifier.size(100.percent, UiLength.Fit).fontSize(9f).foreground(TimelineColors.Muted), - ) -} 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 b35376ebb..99fb61bd5 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 @@ -7,8 +7,6 @@ import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.ide.CutsceneIcon import ru.hollowhorizon.hollowengine.client.ui.inspector.InspectorTarget import ru.hollowhorizon.hollowengine.client.ui.inspector.PublishInspector -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimLayer -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineController import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.TimelineViewMode import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.cutscene.CutsceneEditorSession @@ -17,13 +15,13 @@ 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.ui.style.UiShadow import ru.hollowhorizon.hollowengine.client.utils.lang import kotlin.math.round private const val TimelineZoomWheelStep = 12f private const val TimelineScrollStep = 44f private const val TimelineValueZoomStep = 0.12f +private const val TimelineValuePanStep = 0.08f private const val TimelineMinValueSpan = 0.001f private const val TimelineMaxValueSpan = 100_000f @@ -33,8 +31,6 @@ internal val LocalTimelineRevision = staticCompositionLocalOf { 0 } fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean = true) { session.uiRevision.value var dialog by remember { mutableStateOf(CutsceneDialog.NONE) } - var layerSettings by remember { mutableStateOf(null) } - var propertySettings by remember { mutableStateOf?>(null) } LaunchedEffect(session) { var lastNanos = -1L @@ -63,8 +59,6 @@ fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean onCapture = { session.captureFrame(session.timeline.currentTime) }, onSave = { dialog = CutsceneDialog.SAVE }, onLoad = { dialog = CutsceneDialog.LOAD }, - onLayerSettings = { layerSettings = it }, - onPropertySettings = { propertySettings = it }, ) when (dialog) { @@ -72,12 +66,6 @@ fun CutsceneTimelineDock(session: CutsceneEditorSession, keyboardActive: Boolean CutsceneDialog.LOAD -> LoadCutsceneDialog(session) { dialog = CutsceneDialog.NONE } CutsceneDialog.NONE -> {} } - layerSettings?.let { layer -> - LayerSettingsDialog(session.timeline, layer, session::invalidateUi) { layerSettings = null } - } - propertySettings?.let { property -> - PropertySettingsDialog(session.timeline, property, session::invalidateUi) { propertySettings = null } - } } } } @@ -110,8 +98,7 @@ fun HollowTimelineEditor( onCapture: () -> Unit = {}, onSave: () -> Unit = {}, onLoad: () -> Unit = {}, - onLayerSettings: (AnimLayer) -> Unit = {}, - onPropertySettings: (AnimProperty<*>) -> Unit = {}, + features: TimelineFeatures = TimelineFeatures.CUTSCENE, ) { val bump = refresh val scroll = rememberScrollState() @@ -146,10 +133,20 @@ fun HollowTimelineEditor( } if (focusContentX != null) autoPanToContentX(scroll, laneViewport, focusContentX) + LaunchedEffect(controller.frameTimeRequests) { + if (controller.frameTimeRequests == 0 || laneViewport.width <= 0f) return@LaunchedEffect + val usable = laneViewport.width - TimelineLeftPadding - TimelineFrameMargin + controller.pixelsPerSecond = (usable / controller.workAreaEnd.coerceAtLeast(0.1f)) + .coerceIn(TimelineMinZoom, TimelineMaxZoom) + scroll.scrollTo(x = 0f) + bump() + } + Column( id = "cutscene-timeline-root", + tags = listOf("timeline-root"), modifier = Modifier.size(100.percent, 100.percent) - .background(TimelineColors.Background) + .style(TimelineStylesheet) .focusScope() .onKeyInput { input -> if (keyboardActive) { @@ -158,7 +155,7 @@ fun HollowTimelineEditor( } }, ) { - TimelineToolbar(controller, onCapture, onSave, onLoad, bump) + TimelineToolbar(controller, onCapture, onSave, onLoad, bump, features) Row(modifier = Modifier.size(100.percent, 0.px).grow(1f)) { TimelineHeaders( @@ -168,8 +165,7 @@ fun HollowTimelineEditor( verticalOffset = if (isCurveView) headerScroll.offsetY else scroll.offsetY, ownsVerticalScroll = isCurveView, contentHeight = scrollContentHeight, - onLayerSettings = onLayerSettings, - onPropertySettings = onPropertySettings, + trackScroll = if (isCurveView) headerScroll else scroll, refresh = bump, ) HeaderSplitter(controller, bump) @@ -178,9 +174,8 @@ fun HollowTimelineEditor( // Ruler: horizontally synced to the lane scroll, pinned vertically. Box( id = "timeline-ruler-viewport", - modifier = Modifier.size(100.percent, TimelineRulerHeight.px) - .background(TimelineColors.Group) - .clip(), + tags = listOf("timeline-ruler"), + modifier = Modifier.size(100.percent, TimelineRulerHeight.px).clip(), ) { Box( modifier = Modifier.position((-scroll.offsetX).px, 0.px) @@ -239,8 +234,8 @@ private fun HeaderSplitter(controller: TimelineController, refresh: () -> Unit) val start = remember { floatArrayOf(controller.headerWidth) } Box( id = "timeline-header-splitter", - modifier = Modifier.size(4.px, 100.percent) - .background(TimelineColors.Border) + tags = listOf("timeline-splitter"), + modifier = Modifier.size(2.px, 100.percent) .cursor(UiCursorShape.RESIZE_HORIZONTAL) .input(hoverable = true, draggable = true) .onPress { start[0] = controller.headerWidth } @@ -254,9 +249,9 @@ private fun HeaderSplitter(controller: TimelineController, refresh: () -> Unit) } /** - * Wheel handling for the lane viewport. The raw scroll event carries no modifiers, so query GLFW. - * Ctrl = zoom time around the cursor; in the curve editor Shift zooms the value axis and Alt pans it. - * Otherwise the wheel pans horizontally (the natural timeline axis) and Shift pans vertically. + * Wheel handling for the lane viewport, the way lists scroll everywhere else: the wheel goes down the + * tracks (in the graph, down the values), Shift+wheel goes along the time, Ctrl+wheel zooms the time + * around the pointer and, in the graph, Alt+wheel zooms the values. */ private fun handleTimelineScroll( event: UiEvent, @@ -265,58 +260,61 @@ private fun handleTimelineScroll( viewport: UiRect, refresh: () -> Unit, ) { + event.consume() val modifiers = currentUiKeyModifiers() - // One of scrollX/scrollY is zero; the runtime already folds the wheel onto X when a modifier is held. - val wheel = event.scrollX + event.scrollY - if (wheel == 0f) { - event.consume() - return - } + val down = -event.rawScrollY + val sideways = -event.rawScrollX + if (down == 0f && sideways == 0f) return + if (modifiers and GLFW.GLFW_MOD_CONTROL != 0) { val oldZoom = controller.pixelsPerSecond - val newZoom = (oldZoom - wheel * TimelineZoomWheelStep).coerceIn(TimelineMinZoom, TimelineMaxZoom) - if (newZoom != oldZoom) { - val cursorInViewport = event.x - viewport.x - val cursorContentX = cursorInViewport + scroll.offsetX - val time = (cursorContentX - TimelineLeftPadding) / oldZoom - controller.pixelsPerSecond = newZoom - val newContentX = time * newZoom + TimelineLeftPadding - scroll.scrollTo(x = (newContentX - cursorInViewport).coerceAtLeast(0f)) - refresh() - } - event.consume() + val newZoom = (oldZoom - (down + sideways) * TimelineZoomWheelStep).coerceIn(TimelineMinZoom, TimelineMaxZoom) + if (newZoom == oldZoom) return + val cursorInViewport = event.x - viewport.x + val time = (cursorInViewport + scroll.offsetX - TimelineLeftPadding) / oldZoom + controller.pixelsPerSecond = newZoom + scroll.scrollTo(x = (time * newZoom + TimelineLeftPadding - cursorInViewport).coerceAtLeast(0f)) + refresh() + return + } + + val shift = modifiers and GLFW.GLFW_MOD_SHIFT != 0 + val alongTime = if (shift) down + sideways else sideways + if (alongTime != 0f) scroll.animateScrollBy(deltaX = alongTime * TimelineScrollStep) + if (shift || down == 0f) { + refresh() return } if (controller.viewMode == TimelineViewMode.CURVES && viewport.height > 0f) { val axis = controller.curveAxis - if (modifiers and GLFW.GLFW_MOD_SHIFT != 0) { + if (modifiers and GLFW.GLFW_MOD_ALT != 0) { val height = viewport.height val localY = event.y - viewport.y val cursorValue = curveYToValue(localY, controller.curveValueCenter, controller.curveValueSpan, height) - val span = (axis.targetSpan * (1f + wheel * TimelineValueZoomStep)) + val span = (axis.targetSpan * (1f + down * TimelineValueZoomStep)) .coerceIn(TimelineMinValueSpan, TimelineMaxValueSpan) axis.glideTo(cursorValue + (localY - height * 0.5f) / height * span, span) - refresh() - event.consume() - return - } - if (modifiers and GLFW.GLFW_MOD_ALT != 0) { - axis.glideTo(axis.targetCenter - wheel * axis.targetSpan * 0.05f, axis.targetSpan) - refresh() - event.consume() - return + } else { + axis.glideTo(axis.targetCenter - down * axis.targetSpan * TimelineValuePanStep, axis.targetSpan) } - } - - val amount = wheel * TimelineScrollStep - if (modifiers and GLFW.GLFW_MOD_SHIFT != 0) { - scroll.animateScrollBy(deltaY = amount) } else { - scroll.animateScrollBy(deltaX = amount) + scroll.animateScrollBy(deltaY = down * TimelineScrollStep) } refresh() +} + +/** The wheel over the track list: down the tracks, or with Shift along long names. */ +internal fun scrollTrackList(event: UiEvent, tracks: UiScrollHandle, names: UiScrollHandle) { event.consume() + val down = -event.rawScrollY + val sideways = -event.rawScrollX + if (currentUiKeyModifiers() and GLFW.GLFW_MOD_SHIFT != 0) { + names.animateScrollBy(deltaX = (down + sideways) * TimelineScrollStep) + return + } + if (sideways != 0f) names.animateScrollBy(deltaX = sideways * TimelineScrollStep) + if (down != 0f) tracks.animateScrollBy(deltaY = down * TimelineScrollStep) } @Composable @@ -357,24 +355,23 @@ private fun TimeRuler( while (second <= end) { val x = TimelineLeftPadding + second * pxPerSec Box( - modifier = Modifier.position(x.px, 16.px) - .size(1.px, 14.px) + modifier = Modifier.position(x.px, (TimelineRulerHeight - 6f).px) + .size(1.px, 6.px) .background(TimelineColors.Muted), ) Text( formatSeconds(second), - modifier = Modifier.position((x + 3f).px, 3.px) - .fontSize(9f) - .foreground(TimelineColors.Muted), + tags = listOf("timeline-ruler-label"), + modifier = Modifier.position((x + 3f).px, 2.px), ) if (pxPerSec > 60f) { val minorStep = majorStep / minorPerMajor for (i in 1 until minorPerMajor) { val mx = TimelineLeftPadding + (second + i * minorStep) * pxPerSec Box( - modifier = Modifier.position(mx.px, 22.px) - .size(1.px, 8.px) - .background(TimelineColors.Border), + modifier = Modifier.position(mx.px, (TimelineRulerHeight - 3f).px) + .size(1.px, 3.px) + .background(TimelineColors.Grid), ) } } @@ -389,15 +386,15 @@ private fun TimeRuler( .background(TimelineColors.Accent), ) Box( - modifier = Modifier.position((px - 6f).px, 0.px) - .size(12.px, 12.px) - .shape(PlayheadHeadShape, UiPaint.Color(TimelineColors.Accent)) - .shadow(UiShadow(offset = UiVec3(0f, 0f, 0f), blur = 1.2f, color = PlayheadGlowColor)), + modifier = Modifier.position((px - 5f).px, 0.px) + .size(10.px, 10.px) + .shape(PlayheadHeadShape, UiPaint.Color(TimelineColors.Accent)), ) } } -private val PlayheadGlowColor = UiColor(1f, 0.54f, 0.18f, 0.7f) +/** Room left after the end of the work area when it is fitted into the lanes. */ +private const val TimelineFrameMargin = 24f private fun rulerMajorStep(pxPerSec: Float): Float = when { pxPerSec < 20f -> 5f 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 d4c2d8eee..f01bb1a83 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 @@ -24,15 +24,26 @@ 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) { - val controller = session.timeline + 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. + */ +@Composable +fun TimelineSelectionFields( + controller: TimelineController, + refresh: () -> Unit, + keyframeExtras: @Composable () -> Unit = {}, +) { val selectedKey = controller.selectedKeyframes.firstOrNull() val selectedCurve = selectedKey?.let { controller.curveOf(it) } - PreviewSection(controller, refresh) - OriginSection(session, refresh) when { selectedKey != null && selectedCurve != null -> { - KeyframeSection(session, selectedKey, selectedCurve, refresh) + KeyframeSection(controller, selectedKey, selectedCurve, refresh, keyframeExtras) if (selectedCurve.spec.supportsCurveEditor) { CurveSection(controller, selectedKey, selectedCurve, refresh) } @@ -51,6 +62,16 @@ internal fun HollowTimelineProperties(session: CutsceneEditorSession, refresh: ( } } +/** Whether a timeline has anything selected that [TimelineSelectionFields] would show. */ +fun TimelineController.hasInspectableSelection(): Boolean = selectedKeyframes.isNotEmpty() || isWorkAreaSelected + +/** A key that changes whenever what [TimelineSelectionFields] shows is a different thing. */ +fun TimelineController.selectionKey(): String { + val keyframe = selectedKeyframes.firstOrNull() + ?: return if (isWorkAreaSelected) "work-area" else "none" + return "key-${System.identityHashCode(curveOf(keyframe))}-${System.identityHashCode(keyframe)}" +} + @Composable private fun PreviewSection(controller: TimelineController, refresh: () -> Unit) { Section(CutsceneLang.PREVIEW.lang) { @@ -112,12 +133,12 @@ private fun OriginSection(session: CutsceneEditorSession, refresh: () -> Unit) { @Composable private fun KeyframeSection( - session: CutsceneEditorSession, + controller: TimelineController, keyframe: Keyframe, curve: ChannelCurve, refresh: () -> Unit, + extras: @Composable () -> Unit, ) { - val controller = session.timeline val count = controller.selectedKeyframes.size val title = if (count == 1) CutsceneLang.KEYFRAME.lang else CutsceneLang.KEYFRAMES.lang(count) Section(title) { @@ -127,13 +148,9 @@ private fun KeyframeSection( refresh() } if (curve.spec.valueOptions.isEmpty()) { - val layer = controller.layerOf(keyframe) - val channel = layer?.channels?.indexOf(curve) ?: -1 - val bounds = if (channel >= 0) { - layer?.let(controller::propertyOf)?.bounds(channel) - } else { - null - } ?: ChannelBounds.Unbounded + val owner = controller.propertyOf(keyframe) + val channel = owner?.curves?.indexOfFirst { it === curve } ?: -1 + val bounds = (if (channel >= 0) owner?.bounds(channel) else null) ?: ChannelBounds.Unbounded FloatRow( CutsceneLang.VALUE.lang, keyframe.value, @@ -149,7 +166,7 @@ private fun KeyframeSection( refresh() } } - WorldReadout(session) + extras() } } @@ -166,9 +183,8 @@ private fun DiscreteValueField(options: List, value: Float, } private fun channelPath(controller: TimelineController, curve: ChannelCurve): String { - val layer = controller.allLayers().firstOrNull { it.channels.any { channel -> channel === curve } } - val property = layer?.let { controller.propertyOf(it) } - return listOfNotNull(property?.nameState, layer?.nameState, curve.name).joinToString(" / ") + val property = controller.propertyOf(curve) + return listOfNotNull(property?.nameState, curve.name).joinToString(" / ") } @Composable 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 6d6196c33..327ae03a6 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 @@ -2,7 +2,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.UiColor -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimLayer import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelCurve import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.Keyframe @@ -22,17 +21,17 @@ import kotlin.math.min import kotlin.math.pow import kotlin.math.round -internal const val TimelineRulerHeight = 28f -internal const val TimelineGroupRowHeight = 22f -internal const val TimelinePropertyRowHeight = 24f -internal const val TimelineLayerRowHeight = 22f -internal const val TimelineChannelRowHeight = 20f -internal const val TimelineLeftPadding = 58f -internal const val CurveValueGutter = 46f +internal const val TimelineRulerHeight = 18f + +internal const val TimelineGroupRowHeight = 17f +internal const val TimelinePropertyRowHeight = 16f +internal const val TimelineChannelRowHeight = 14f +internal const val TimelineLeftPadding = 40f +internal const val CurveValueGutter = 40f internal const val TimelineMinContentWidth = 600f internal const val TimelineMaxZoom = 500f internal const val TimelineMinZoom = 10f -internal const val TimelineMinHeaderWidth = 150f +internal const val TimelineMinHeaderWidth = 120f internal const val TimelineMaxHeaderWidth = 480f private const val TimelineAutoPanEdge = 48f internal const val TimelineScrollbarClearance = 12f @@ -58,7 +57,6 @@ internal val PlayheadHeadShape: Shape = GenericShape { size -> internal enum class TimelineRowKind { GROUP, PROPERTY, - LAYER, CHANNEL, } @@ -71,7 +69,6 @@ internal data class TimelineRow( val kind: TimelineRowKind, val group: TrackGroup? = null, val property: AnimProperty<*>? = null, - val layer: AnimLayer? = null, val curve: ChannelCurve? = null, val locked: Boolean = false, val visible: Boolean = true, @@ -80,9 +77,8 @@ internal data class TimelineRow( val curves: List get() = when { curve != null -> listOf(curve) - layer != null -> layer.channels - property != null -> property.layers.flatMap { it.channels } - group != null -> group.allProperties().flatMap { owner -> owner.layers.flatMap { it.channels } } + property != null -> property.curves + group != null -> group.allProperties().flatMap { it.curves } else -> emptyList() } } @@ -92,6 +88,7 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole var y = TimelineRulerHeight fun appendGroup(group: TrackGroup, depth: Int, parentLocked: Boolean, parentVisible: Boolean) { + if (!group.hasListedContent) return if (curveEditorOnly && group.allProperties().none { property -> property.channels.any { it.supportsCurveEditor } }) return @@ -113,7 +110,12 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole group.children.forEach { appendGroup(it, depth + 1, locked, visible) } group.properties.forEach { property -> + if (!property.isListed) return@forEach if (curveEditorOnly && property.channels.none { it.supportsCurveEditor }) return@forEach + val propertyVisible = visible && property.isVisible + val propertyLocked = locked || property.isLocked + + val singleCurve = property.curves.singleOrNull() rows += TimelineRow( id = "timeline-property-${System.identityHashCode(property)}", label = property.nameState, @@ -122,46 +124,31 @@ internal fun timelineRows(controller: TimelineController, curveEditorOnly: Boole height = TimelinePropertyRowHeight, kind = TimelineRowKind.PROPERTY, property = property, - locked = locked, - visible = visible && property.layers.any { it.isVisible }, + curve = singleCurve, + locked = propertyLocked, + visible = propertyVisible && (singleCurve?.isVisible ?: true), + color = singleCurve?.color, ) y += TimelinePropertyRowHeight if (!property.isExpanded) return@forEach - property.layers.forEach { layer -> - val layerVisible = visible && layer.isVisible + if (property.curves.size <= 1) return@forEach + + property.curves.filter { !curveEditorOnly || it.spec.supportsCurveEditor }.forEach { curve -> rows += TimelineRow( - id = "timeline-layer-${System.identityHashCode(layer)}", - label = layer.nameState, + id = "timeline-channel-${System.identityHashCode(curve)}", + label = curve.name, depth = depth + 2, y = y, - height = TimelineLayerRowHeight, - kind = TimelineRowKind.LAYER, + height = TimelineChannelRowHeight, + kind = TimelineRowKind.CHANNEL, property = property, - layer = layer, - locked = locked || layer.isLocked, - visible = layerVisible, + curve = curve, + locked = propertyLocked, + visible = propertyVisible && curve.isVisible, + color = curve.color, ) - y += TimelineLayerRowHeight - if (!layer.isExpanded) return@forEach - - layer.channels.filter { !curveEditorOnly || it.spec.supportsCurveEditor }.forEach { curve -> - rows += TimelineRow( - id = "timeline-channel-${System.identityHashCode(curve)}", - label = curve.name, - depth = depth + 3, - y = y, - height = TimelineChannelRowHeight, - kind = TimelineRowKind.CHANNEL, - property = property, - layer = layer, - curve = curve, - locked = locked || layer.isLocked, - visible = layerVisible && curve.isVisible, - color = curve.color, - ) - y += TimelineChannelRowHeight - } + y += TimelineChannelRowHeight } } } @@ -267,18 +254,22 @@ internal fun Color.toUiColor(alphaMultiplier: Float = 1f): UiColor { internal fun UiColor.withAlpha(multiplier: Float) = UiColor(red, green, blue, (alpha * multiplier).coerceIn(0f, 1f)) +/** + * 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 { - val Background = UiColor(0.07f, 0.08f, 0.1f, 1f) - val Panel = UiColor(0.1f, 0.11f, 0.14f, 1f) - val PanelAlt = UiColor(0.13f, 0.14f, 0.17f, 1f) - val Row = UiColor(0.11f, 0.12f, 0.14f, 1f) - val Group = UiColor(0.14f, 0.15f, 0.18f, 1f) - val Muted = UiColor(0.58f, 0.62f, 0.7f, 1f) - val Text = UiColor(0.88f, 0.9f, 0.94f, 1f) - val Accent = UiColor(1f, 0.54f, 0.18f, 1f) - val Blue = UiColor(0.34f, 0.58f, 0.88f, 1f) - val Border = UiColor(0.24f, 0.26f, 0.3f, 1f) + val Background = UiColor(0.110f, 0.118f, 0.133f, 1f) + val Panel = UiColor(0.110f, 0.118f, 0.133f, 1f) + val PanelAlt = UiColor(0.169f, 0.180f, 0.208f, 1f) + val Row = UiColor(0.118f, 0.125f, 0.141f, 1f) + val Group = UiColor(0.129f, 0.137f, 0.157f, 1f) + val Muted = UiColor(0.561f, 0.608f, 0.678f, 1f) + val Text = UiColor(0.769f, 0.796f, 0.855f, 1f) + val Accent = UiColor(0.431f, 0.608f, 0.863f, 1f) + val Blue = UiColor(0.682f, 0.722f, 0.792f, 1f) + val Border = UiColor(0.149f, 0.157f, 0.180f, 1f) val Danger = UiColor(0.76f, 0.23f, 0.23f, 1f) - val Grid = UiColor(0.18f, 0.19f, 0.23f, 1f) + val Grid = UiColor(0.176f, 0.188f, 0.216f, 1f) val Handle = UiColor(0.72f, 0.74f, 0.8f, 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 new file mode 100644 index 000000000..9dd96dc24 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/timeline/ui/IdeTimelineHost.kt @@ -0,0 +1,213 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.isActive +import ru.hollowhorizon.hollowengine.client.ui.Box +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.HollowUiContent +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.Row +import ru.hollowhorizon.hollowengine.client.ui.UiBoxMode +import ru.hollowhorizon.hollowengine.client.ui.grow +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.ui.percent +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.scrollable +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.style +import ru.hollowhorizon.hollowengine.client.utils.lang + +/** + * What the timeline window is showing. + */ +class TimelineTarget( + val id: String, + val controller: TimelineController, + /** Called when the editor changed something the owner has to write down. */ + val refresh: () -> Unit, + val onKeyInput: (Int, Int) -> Boolean = { _, _ -> false }, + /** What the record button does, or null when this target has nothing to capture. */ + val onCapture: (() -> Unit)? = null, + val onSave: (() -> Unit)? = null, + val onLoad: (() -> Unit)? = null, + /** Advances whatever the timeline is driving, in seconds, while it is playing. */ + val advance: ((Float) -> Unit)? = null, + /** Dialogs and chrome the owner wants drawn over the timeline. */ + val overlay: HollowUiContent? = null, +) + +/** + * Whose timeline the shared window shows, wherever it is drawn. + * + * Last publisher wins, the same way the inspector works. + */ +object IdeTimelines { + var current: TimelineTarget? by mutableStateOf(null) + private set + + private var owner: String? = null + + fun publish(source: String, target: TimelineTarget?) { + if (target == null) { + release(source) + return + } + if (owner == source && current?.id == target.id) return + owner = source + current = target + } + + fun release(source: String) { + if (owner != source) return + owner = null + current = null + } +} + +/** + * Keeps [target] in the timeline window for as long as this editor is composed. + */ +@Composable +fun PublishTimeline(source: String, key: Any?, target: () -> TimelineTarget?) { + LaunchedEffect(source, key) { IdeTimelines.publish(source, target()) } + DisposableEffect(source) { onDispose { IdeTimelines.release(source) } } +} + +@Composable +fun TimelineDock(keyboardActive: Boolean = true) { + val target = IdeTimelines.current + if (target == null) { + CutsceneTimelineDock(CutsceneEditorSessions.default, keyboardActive) + return + } + + val controller = target.controller + + val revision = remember(target.id) { mutableStateOf(0) } + val refresh: () -> Unit = remember(target) { + { + target.refresh() + revision.value++ + } + } + revision.value + + LaunchedEffect(target.id) { + 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 (controller.isPlaying) target.advance?.invoke(delta) + if (controller.curveAxis.advance(now)) revision.value++ + } + } + } + + PublishInspector(source = "timeline-${target.id}", key = controller.selectionKey()) { + if (!controller.hasInspectableSelection()) return@PublishInspector null + InspectorTarget( + id = "timeline-${target.id}-${controller.selectionKey()}", + title = CutsceneLang.PROPERTIES.lang, + icon = CutsceneIcon, + ) { + revision.value + TimelineSelectionFields(controller, refresh) + } + } + + CompositionLocalProvider(LocalTimelineRevision provides revision.value) { + Box(id = "ide-timeline-dock", mode = UiBoxMode.STACK, modifier = Modifier.size(100.percent, 100.percent)) { + HollowTimelineEditor( + controller = controller, + refresh = refresh, + onKeyInput = { key, modifiers -> + val handled = target.onKeyInput(key, modifiers) || TimelineKeys.handle(controller, key, modifiers) + if (handled) refresh() + handled + }, + keyboardActive = keyboardActive, + onCapture = { target.onCapture?.invoke() }, + onSave = { target.onSave?.invoke() }, + onLoad = { target.onLoad?.invoke() }, + features = TimelineFeatures( + capture = target.onCapture != null, + storage = target.onSave != null, + cameraPreview = false, + ), + ) + target.overlay?.invoke() + } + } +} + +/** + * Timeline editor as a part of some other window. + */ +@Composable +fun EmbeddedTimelineEditor( + controller: TimelineController, + onChanged: () -> Unit, + features: TimelineFeatures, + modifier: Modifier = Modifier.size(100.percent, 100.percent), + selectionPanel: Boolean = true, +) { + val revision = remember(controller) { mutableStateOf(0) } + val refresh: () -> Unit = remember(controller) { + { + onChanged() + revision.value++ + } + } + revision.value + + LaunchedEffect(controller) { + while (isActive) { + withFrameNanos { now -> if (controller.curveAxis.advance(now)) revision.value++ } + } + } + + CompositionLocalProvider(LocalTimelineRevision provides revision.value) { + Row(modifier = modifier) { + Box(mode = UiBoxMode.STACK, modifier = Modifier.size(0.px, 100.percent).grow(1f)) { + HollowTimelineEditor( + controller = controller, + refresh = refresh, + onKeyInput = { key, modifiers -> + TimelineKeys.handle(controller, key, modifiers).also { handled -> if (handled) refresh() } + }, + keyboardActive = true, + features = features, + ) + } + if (selectionPanel) { + Column( + tags = listOf("insp-panel"), + modifier = Modifier.size(EmbeddedSelectionWidth.px, 100.percent).style(InspectorStylesheet) + .scrollable(horizontal = false), + ) { + revision.value + TimelineSelectionFields(controller, refresh) + } + } + } + } +} + +private const val EmbeddedSelectionWidth = 220f 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 01fc22fbf..4dafa75cb 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,14 +1,20 @@ package ru.hollowhorizon.hollowengine.client.ui.inspector import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import ru.hollowhorizon.hollowengine.client.ui.Box import ru.hollowhorizon.hollowengine.client.ui.Column import ru.hollowhorizon.hollowengine.client.ui.HollowUiContent import ru.hollowhorizon.hollowengine.client.ui.Image import ru.hollowhorizon.hollowengine.client.ui.Modifier import ru.hollowhorizon.hollowengine.client.ui.Row import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle +import ru.hollowhorizon.hollowengine.client.ui.scroll.rememberScrollState import ru.hollowhorizon.hollowengine.client.ui.scrollable import ru.hollowhorizon.hollowengine.client.ui.style @@ -57,6 +63,9 @@ val LocalInspectorHost: ProvidableCompositionLocal = composition /** * The panel itself: heading, then the target's own fields under a scrollbar. [leading] is for chrome * an editor wants beside the title, such as a back arrow or a layer switcher. + * + * With [keepScroll] the scroll position outlives the panel: hiding the window or switching its tab + * away and back returns to the same place, while showing a different target starts at the top. */ @Composable fun InspectorPanel( @@ -65,14 +74,18 @@ fun InspectorPanel( empty: String = "", leading: HollowUiContent? = null, trailing: HollowUiContent? = null, + keepScroll: Boolean = false, ) { val styled = target?.styles.orEmpty() .fold(modifier.style(InspectorStylesheet)) { chain, sheet -> chain.style(sheet) } + val scroll = rememberScrollState() + if (keepScroll) KeepScroll(target?.id, scroll) + Column( - id = "inspector-${target?.id ?: "empty"}", + id = "inspector-panel", tags = listOf("insp-panel"), - modifier = styled.scrollable(horizontal = false), + modifier = styled.scrollable(horizontal = false, state = scroll), ) { if (leading != null || trailing != null || target != null) { Row(tags = listOf("insp-head")) { @@ -85,10 +98,38 @@ fun InspectorPanel( } if (target == null) { - if (empty.isNotEmpty()) Text(empty, tags = listOf("insp-empty")) + if (empty.isNotEmpty()) { + Column(tags = listOf("insp-empty-state")) { + Box(tags = listOf("insp-empty-icon")) + Text(empty, tags = listOf("insp-empty")) + } + } return@Column } Column(tags = listOf("insp-body")) { target.content() } } } + +@Composable +private fun KeepScroll(targetId: String?, scroll: UiScrollHandle) { + val saved = remember { InspectorScrollMemory.offset } + + LaunchedEffect(targetId) { + if (InspectorScrollMemory.target == targetId) { + scroll.scrollTo(y = saved) + } else { + InspectorScrollMemory.target = targetId + InspectorScrollMemory.offset = 0f + scroll.scrollTo(x = 0f, y = 0f) + } + } + LaunchedEffect(scroll) { + snapshotFlow { scroll.offsetY }.collect { InspectorScrollMemory.offset = it } + } +} + +private object InspectorScrollMemory { + var target: String? = null + var offset: Float = 0f +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorTree.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssetTree.kt similarity index 94% rename from runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorTree.kt rename to runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssetTree.kt index 904a98024..052b7d1e3 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/entity/EntityEditorTree.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorAssetTree.kt @@ -1,10 +1,10 @@ -package ru.hollowhorizon.hollowengine.client.ui.entity +package ru.hollowhorizon.hollowengine.client.ui.inspector -import ru.hollowhorizon.hollowengine.client.ui.inspector.* import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTreeItem -internal object PathTree { +/** Asset paths as a folder tree: namespace first, then each folder of the path. */ +internal object AssetPathTree { fun rows( paths: List, expanded: Set, 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 0ab930101..d46d67b8e 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 @@ -1,9 +1,21 @@ package ru.hollowhorizon.hollowengine.client.ui.inspector import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import net.minecraft.client.Minecraft +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.widgets.UiCompletionContributor import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextCompletion +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnostic +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextDiagnosticSeverity +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextEdit +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTextQuickFix +import ru.hollowhorizon.hollowengine.client.ui.widgets.UiTreeView import ru.hollowhorizon.hollowengine.client.utils.IconHelper @@ -12,6 +24,17 @@ private const val AssetCompletionLimit = 60 /** Opens host's asset picker for an `@EditorAsset` field; absent when the host has no picker. */ @Composable internal fun AssetPickerButton(extensions: List, current: String, label: String, onPick: (String) -> Unit) { + AssetPickerButton(label, current, { host -> host.assets(extensions) }, onPick) +} + +/** The same, for a field whose candidates are not plain files, such as shader names. */ +@Composable +fun AssetPickerButton( + label: String, + current: String, + candidates: (InspectorHost) -> List, + onPick: (String) -> Unit, +) { val host = LocalInspectorHost.current ?: return if (!host.canPickAssets) return @@ -20,11 +43,11 @@ internal fun AssetPickerButton(extensions: List, current: String, label: InspectorLang.pick, tags = listOf("insp-pick-button"), ) { - host.pickAsset(InspectorAssetRequest(label, host.assets(extensions), current, onPick)) + host.pickAsset(InspectorAssetRequest(label, candidates(host), current, onPick)) } } -internal fun assetCompletions(candidates: List): UiCompletionContributor = UiCompletionContributor { context -> +fun assetCompletions(candidates: List): UiCompletionContributor = UiCompletionContributor { context -> val prefix = context.text.take(context.caret).trim() candidates.asSequence().filter { prefix.isBlank() || it.contains(prefix, ignoreCase = true) } .take(AssetCompletionLimit).map { candidate -> @@ -37,6 +60,76 @@ internal fun assetCompletions(candidates: List): UiCompletionContributor }.toList() } +/** + * An underline under [value] when [exists] says there is nothing there, with a fix for every + * candidate that ends the same way: a texture moved to another folder or namespace is the usual case. + */ +fun assetDiagnostics(value: String, candidates: List, exists: (String) -> Boolean): List { + val path = value.trim() + if (path.isEmpty() || exists(path)) return emptyList() + + val name = path.substringAfterLast('/').substringAfterLast(':') + val fixes = candidates.asSequence() + .filter { it != path && it.substringAfterLast('/').substringAfterLast(':') == name } + .take(MaxAssetFixes) + .map { UiTextQuickFix(InspectorLang.useAsset(it), listOf(UiTextEdit(0, value.length, it))) } + .toList() + return listOf( + UiTextDiagnostic( + start = 0, + end = value.length, + message = InspectorLang.assetMissing(path), + severity = UiTextDiagnosticSeverity.ERROR, + fixes = fixes, + ) + ) +} + +private const val MaxAssetFixes = 5 + +/** Whether the resource pack stack has a file at [path], a `namespace:path` location. */ +fun resourceExists(path: String): Boolean { + val location = ResourceLocation.tryParse(path) ?: return false + val manager = Minecraft.getInstance().resourceManager ?: return true + return manager.getResource(location).isPresent +} + +/** + * A resource path typed with completions, checked against the loaded packs, and picked from a tree. + * + * [candidates] lists what can be picked; [exists] decides whether what is typed is there. Both are + * given so a field can name something other than a file, such as a shader by its short name. + */ +@Composable +fun AssetPathField( + value: String, + label: String, + id: String, + candidates: (InspectorHost) -> List, + exists: (String) -> Boolean = ::resourceExists, + placeholder: String = "", + onChange: (String) -> Unit, +) { + val host = LocalInspectorHost.current + val listed = remember(host) { host?.let(candidates).orEmpty() } + val diagnostics = remember(value, listed) { assetDiagnostics(value, listed, exists) } + + Row(tags = listOf("insp-input-row")) { + TextField( + value = value, + id = id, + fontSize = 9f, + placeholder = placeholder, + completionContributor = 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), + ) + AssetPickerButton(label, value, candidates, onChange) + } +} + /** * Where an `@EditorAsset` field's suggestions come from. */ @@ -64,3 +157,89 @@ object EditorAssetSources { }.getOrDefault(emptyList()) } } + +private val CenteredOnViewport = UiPopupAlignment( + anchorHorizontal = UiAlign.CENTER, + anchorVertical = UiAlign.CENTER, + popupHorizontal = UiAlign.CENTER, + popupVertical = UiAlign.CENTER, +) + +/** A modal window in the middle of the screen, in the inspector's look. */ +@Composable +fun InspectorDialog(id: String, title: String, width: Float, onClose: () -> Unit, content: HollowUiContent) { + val viewport = LocalUiViewport.current + Popup( + anchorBounds = viewport, + alignment = CenteredOnViewport, + id = "$id-popup", + layer = 100, + modal = true, + onDismiss = onClose, + ) { + Column( + id = id, + tags = listOf("insp-dialog"), + modifier = Modifier.style(InspectorStylesheet).size(width.px, UiLength.Fit) + .maxSize(height = (viewport.height - 80f).coerceAtLeast(160f).px), + ) { + Row(tags = listOf("insp-dialog-head")) { + Text(title, tags = listOf("insp-dialog-title"), modifier = Modifier.grow(1f)) + InspectorIconButton(InspectorIcons.CLOSE, InspectorLang.close) { onClose() } + } + content() + } + } +} + +/** The tree of everything [request] offers, filtered as the user types. */ +@Composable +fun AssetPickerDialog(request: InspectorAssetRequest, onClose: () -> Unit) { + var filter by remember(request) { mutableStateOf("") } + val expanded = remember(request) { mutableStateSetOf() } + + val rows = AssetPathTree.rows( + paths = request.candidates, + expanded = expanded, + query = filter, + folderIcon = InspectorIcons.FOLDER, + fileIcon = { IconHelper.forPath(it).toString() }, + selected = request.current, + ) + + InspectorDialog("insp-asset-dialog", request.title, 420f, onClose) { + Row(tags = listOf("insp-search")) { + Image(InspectorIcons.SEARCH, tags = listOf("insp-search-icon")) + TextField( + value = filter, + id = "insp-asset-filter", + placeholder = InspectorLang.search, + fontSize = 9f, + onChange = { filter = it }, + tags = listOf("insp-input", "flat"), + modifier = Modifier.size(0.px, UiLength.Fit).grow(1f), + ) + } + + if (rows.isEmpty()) { + Text(InspectorLang.nothingFound, tags = listOf("insp-hint")) + return@InspectorDialog + } + + UiTreeView( + items = rows, + onToggle = { item -> if (!expanded.add(item.id)) expanded.remove(item.id) }, + onSelect = { item, _ -> + val picked = item.payload + if (picked == null) { + if (!expanded.add(item.id)) expanded.remove(item.id) + } else { + request.onPick(picked) + onClose() + } + }, + modifier = Modifier.size(100.percent, 280.px), + tags = listOf("insp-asset-tree"), + ) + } +} 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 0b748675b..0e4e9553f 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 @@ -17,25 +17,35 @@ fun Section(title: String, id: String? = null, boxed: Boolean = false, content: } } -/** A section that folds away; [expanded] stays the caller's state so it survives a rebuild. */ +/** + * A section that folds away, headed by a bar with [icon] so it reads apart from the fields in it. + * [expanded] stays the caller's state so it survives a rebuild; [trailing] sits at the end of the bar, + * for the actions that belong to the whole section. + */ @Composable fun CollapsibleSection( title: String, expanded: Boolean, id: String? = null, + icon: String? = null, + trailing: HollowUiContent? = null, onToggle: () -> Unit, content: HollowUiContent, ) { - Column(id = id, tags = listOf("insp-section")) { + Column(id = id, tags = listOf("insp-section", "insp-section-fold")) { Row( + id = id?.let { "$it-head" }, tags = listOf("insp-section-head"), + attributes = mapOf("expanded" to expanded.toString()), modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND).onClick { event -> if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onToggle() event.consume() }, ) { - DisclosureArrow(expanded) + icon?.let { Image(it, tags = listOf("insp-section-icon")) } Text(title, tags = listOf("insp-section-title")) + trailing?.invoke() + DisclosureArrow(expanded) } if (expanded) Column(tags = listOf("insp-section-body"), content = content) } @@ -146,13 +156,19 @@ fun NumberRow( } @Composable -fun ToggleRow(label: String, checked: Boolean, switch: Boolean = false, onChange: (Boolean) -> Unit) { +fun ToggleRow( + label: String, + checked: Boolean, + switch: Boolean = false, + hint: String? = null, + onChange: (Boolean) -> Unit, +) { Row( tags = listOf("insp-check-row"), modifier = Modifier.input(hoverable = true, clickable = true).cursor(UiCursorShape.HAND).onClick { event -> if (event.button == GLFW.GLFW_MOUSE_BUTTON_LEFT) onChange(!checked) event.consume() - }, + }.let { if (hint != null) it.tooltipOnHover(hint) else it }, ) { if (switch) { Text(label, tags = listOf("insp-check-label")) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorLang.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorLang.kt index d59aace2e..2459c3d89 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorLang.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/inspector/InspectorLang.kt @@ -12,6 +12,12 @@ object InspectorLang { val add: String get() = (ROOT + "add").lang val remove: String get() = (ROOT + "remove").lang val pick: String get() = (ROOT + "pick").lang + val search: String get() = (ROOT + "search").lang + val nothingFound: String get() = (ROOT + "nothing_found").lang + val close: String get() = (ROOT + "close").lang + + fun assetMissing(path: String): String = (ROOT + "asset_missing").lang.format(path) + fun useAsset(path: String): String = (ROOT + "asset_use").lang.format(path) fun unsupported(type: String): String = (ROOT + "unsupported").lang.format(type) } @@ -21,4 +27,6 @@ internal object InspectorIcons { const val REMOVE = "hollowengine:textures/gui/icons/remove.svg" const val FOLDER = "hollowengine:textures/gui/icons/folder.svg" const val HELP = "hollowengine:textures/gui/icons/docs.svg" + const val SEARCH = "hollowengine:textures/gui/icons/search.svg" + const val CLOSE = "hollowengine:textures/gui/icons/cross.svg" } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiInlineFlow.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiInlineFlow.kt index 4e0ffdc1b..aa8921bbb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiInlineFlow.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/layout/UiInlineFlow.kt @@ -116,6 +116,8 @@ private class WordPiece( val sourceStart: Int = 0, /** Resolved font family the word was measured with (needed to re-measure when splitting). */ val fontFamily: String? = null, + /** The size it was measured at; [height] is the font's line, which is taller. */ + val fontSize: Float = height, ) : Piece() private class SpacePiece( @@ -192,7 +194,7 @@ internal fun UiLayoutPipeline.computeInlineFlow( ) val lines = breakIntoLines(pieces, availableWidth, wrap) // Alignment still uses the container width even with wrap off, so a single line can sit - // centred/right/justified when the container is wider than the text (a fit container just + // centered/right/justified when the container is wider than the text (a fit container just // hugs the text, making alignment a natural no-op). positionLines(lines, availableWidth, align, lineSpacing) @@ -297,6 +299,7 @@ private fun tokenizeSpanWords(span: SpanNode, style: UiComputedStyle, group: Gro val effects = style.textEffects val inlineStyle = UiInlineStyle(effects = effects) val spaceWidth = style.spaceWidth ?: UiTextLayouter.measureStyledTextWidth(" ", fontSize, fontFamily, inlineStyle) + val height = UiTextFonts.resolve(fontFamily).lineHeight(fontSize) val preserve = style.whitespace == UiWhitespace.PRESERVE val text = span.text var wordStart = -1 @@ -304,7 +307,10 @@ private fun tokenizeSpanWords(span: SpanNode, style: UiComputedStyle, group: Gro if (wordStart < 0) return val wordText = text.substring(wordStart, end) val width = UiTextLayouter.measureStyledTextWidth(wordText, fontSize, fontFamily, inlineStyle) - out += WordPiece(wordText, inlineStyle, width, fontSize, group, sourceStart = wordStart, fontFamily = fontFamily) + out += WordPiece( + wordText, inlineStyle, width, height, group, + sourceStart = wordStart, fontFamily = fontFamily, fontSize = fontSize, + ) wordStart = -1 } for (index in text.indices) { @@ -318,9 +324,9 @@ private fun tokenizeSpanWords(span: SpanNode, style: UiComputedStyle, group: Gro ch.isInlineWhitespace() -> { flush(index) if (preserve) { - out += SpacePiece(spaceWidth, fontSize, group, preserve = true, sourceStart = index) + out += SpacePiece(spaceWidth, height, group, preserve = true, sourceStart = index) } else if (out.lastOrNull() !is SpacePiece) { - out += SpacePiece(spaceWidth, fontSize, group, sourceStart = index) + out += SpacePiece(spaceWidth, height, group, sourceStart = index) } } @@ -447,14 +453,16 @@ private fun breakIntoLines(pieces: List, wrapWidth: Float, wrap: Boolean) /** Splits a word wider than [width] into glyph-boundary chunks, keeping source offsets intact. */ private fun splitOversizedWordPiece(word: WordPiece, width: Float): List { - fun measure(text: String) = UiTextLayouter.measureStyledTextWidth(text, word.height, word.fontFamily, word.style) + fun measure(text: String) = UiTextLayouter.measureStyledTextWidth(text, word.fontSize, word.fontFamily, word.style) val chunks = mutableListOf() val buffer = StringBuilder() var chunkStart = word.sourceStart fun flush() { if (buffer.isEmpty()) return val text = buffer.toString() - chunks += WordPiece(text, word.style, measure(text), word.height, word.group, chunkStart, word.fontFamily) + chunks += WordPiece( + text, word.style, measure(text), word.height, word.group, chunkStart, word.fontFamily, word.fontSize, + ) chunkStart += text.length buffer.setLength(0) } 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 c36a0ccfd..c5fa3858e 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 @@ -418,6 +418,27 @@ internal fun nodeBoxes(rect: UiRect, style: UiComputedStyle, reserve: UiScrollba ) } +/** + * Where a clipping node cuts its children. + */ +internal fun overflowClipRect(rect: UiRect, style: UiComputedStyle, content: UiRect): UiRect { + val scroll = style.scroll ?: return content + if (scroll.horizontal && scroll.vertical) return content + val border = style.border.width.resolve(rect.width, rect.height) + val paddingBox = UiRect( + rect.x + border.left, + rect.y + border.top, + (rect.width - border.left - border.right).coerceAtLeast(0f), + (rect.height - border.top - border.bottom).coerceAtLeast(0f), + ) + return UiRect( + x = if (scroll.horizontal) content.x else paddingBox.x, + y = if (scroll.vertical) content.y else paddingBox.y, + width = if (scroll.horizontal) content.width else paddingBox.width, + height = if (scroll.vertical) content.height else paddingBox.height, + ) +} + internal fun UiComputedStyle.verticalGutter(reserve: UiScrollbarReserve, reference: Float): Float = if (reserve.vertical) scrollbar.resolved(reference).gutter else 0f 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 22188dc93..6eed55330 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 @@ -33,6 +33,7 @@ data class UiLayoutNode( val content: UiRect, val clip: UiRect?, val outerClip: UiRect? = null, + val overflowClip: UiRect = content, val worldTransform: UiMatrix4, val inputTransform: UiMatrix4, val needsFramebuffer: Boolean, 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 7e7358ea9..032b6f3ca 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 @@ -26,7 +26,8 @@ internal fun UiLayoutPipeline.placeNodeNow( 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 clip = if (style.clip || style.scrollable) parentClip.intersect(boxes.content) else parentClip + 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 @@ -66,6 +67,7 @@ internal fun UiLayoutPipeline.placeNodeNow( content = boxes.content, clip = clip, outerClip = parentClip, + overflowClip = overflowClip, worldTransform = transform, inputTransform = inputTransform, needsFramebuffer = needsFramebuffer, 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 418f8658f..42e2de07e 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 @@ -71,10 +71,10 @@ private const val ProjectiveLayerTextureSubdivisions = 12 private const val PORTRAIT_YAW = 180f -/** Colour links are drawn in when the style does not name one. */ +/** Color links are drawn in when the style does not name one. */ private val LinkColor = UiColor(0.34f, 0.67f, 1f, 1f) -/** Below this an underline can fall between pixel centres and vanish entirely. */ +/** Below this an underline can fall between pixel centers and vanish entirely. */ private const val MinTextRuleThickness = 0.5f /** Corner rounding of a span background or an inline code chip. */ @@ -2018,6 +2018,7 @@ class MinecraftUiRenderer { command.filter, command.slice, command.tint, + command.uv, ) } @@ -2387,7 +2388,7 @@ class MinecraftUiRenderer { if (target == null) { Minecraft.getInstance().mainRenderTarget.bindWrite(true) val window = Minecraft.getInstance().window - GL11.glViewport(0, 0, window.width, window.height) + RenderSystem.viewport(0, 0, window.width, window.height) restoreMainProjection() return } @@ -2459,7 +2460,7 @@ class MinecraftUiRenderer { private fun bindTarget(target: RenderTargetState) { GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, target.framebufferId) - GL11.glViewport(target.x, target.y, target.width, target.height) + RenderSystem.viewport(target.x, target.y, target.width, target.height) } private fun configureLayerProjection(width: Float, height: Float) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatch.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatch.kt index 892352792..c825a0b26 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatch.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatch.kt @@ -6,8 +6,10 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiFilterChain import ru.hollowhorizon.hollowengine.client.ui.style.UiShadow import ru.hollowhorizon.hollowengine.client.ui.text.UiGlyphAtlasPage import java.nio.FloatBuffer +import kotlin.math.PI import kotlin.math.abs import kotlin.math.min +import kotlin.math.roundToInt internal class UiAnalyticRectBatch { private val instances = UiFloatArrayBuilder() @@ -89,8 +91,10 @@ internal class UiAnalyticRectBatch { paintEncoder.append(borderPaint, command.opacity, command.filter, width, height) } val radius = command.border.radius.coerceIn(0f, min(width, height) * 0.5f) + val dash = command.border.dash?.takeIf { !it.isSolid && borderWidth > 0f } + ?.let { borderDashPattern(width, height, radius, borderWidth, it) } records.add(width, height, radius, borderWidth) - records.add(paintIndex.toFloat(), borderPaintIndex.toFloat(), 0f, 0f) + records.add(paintIndex.toFloat(), borderPaintIndex.toFloat(), dash?.period ?: 0f, -(dash?.length ?: 0f)) records.add(borderColor.red, borderColor.green, borderColor.blue, borderColor.alpha) records.add(clip.minX, clip.minY, clip.maxX, clip.maxY) appendInstance(transform, 0f, 0f, width, height) @@ -233,6 +237,27 @@ internal class UiAnalyticRectBatch { } } +/** A dash pattern fitted to one border: [period] divides its center line into whole repeats. */ +internal class UiBorderDashPattern(val period: Float, val length: Float) + +internal fun borderDashPattern( + width: Float, + height: Float, + radius: Float, + borderWidth: Float, + dash: UiBorderDash, +): UiBorderDashPattern { + val inset = borderWidth * 0.5f + val halfWidth = width * 0.5f - inset + val halfHeight = height * 0.5f - inset + val centreRadius = (radius - inset).coerceIn(0f, min(halfWidth, halfHeight)) + val perimeter = 4f * (halfWidth - centreRadius) + 4f * (halfHeight - centreRadius) + 2f * PI.toFloat() * centreRadius + val repeat = dash.length + dash.gap + val count = (perimeter / repeat).roundToInt().coerceAtLeast(1) + val period = perimeter / count + return UiBorderDashPattern(period, period * dash.length / repeat) +} + /** * An axis-aligned clip rectangle in the batch's effective (pre-projection) screen space, encoded * per primitive so the fragment shader can discard out-of-bounds pixels, replacing the GL scissor diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectRenderer.kt index 2ddfd884c..3b7624237 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectRenderer.kt @@ -10,6 +10,7 @@ import org.lwjgl.opengl.GL30 import org.lwjgl.opengl.GL31 import org.lwjgl.opengl.GL33 import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.ui.text.GlyphEdgeSoftness import java.nio.FloatBuffer internal class UiAnalyticRectRenderer : UiSdfRenderer( @@ -29,6 +30,7 @@ internal class UiAnalyticRectRenderer : UiSdfRenderer( private val fontAtlasLocations = IntArray(UiAnalyticRectBatch.MaxGlyphPages) { -1 } private var glyphDistanceRangeLocation = -1 private var glyphAtlasSizeLocation = -1 + private var glyphEdgeSoftnessLocation = -1 override val vertexStrideFloats get() = UiAnalyticRectBatch.InstanceStride @@ -39,10 +41,11 @@ internal class UiAnalyticRectRenderer : UiSdfRenderer( } glyphDistanceRangeLocation = GL20.glGetUniformLocation(program, "GlyphDistanceRange") glyphAtlasSizeLocation = GL20.glGetUniformLocation(program, "GlyphAtlasSize") - assignGlyphSamplerUnits() + glyphEdgeSoftnessLocation = GL20.glGetUniformLocation(program, "GlyphEdgeSoftness") + assignConstantUniforms() } - private fun assignGlyphSamplerUnits() { + private fun assignConstantUniforms() { val missing = fontAtlasLocations.indexOfFirst { it < 0 } if (missing >= 0) { HollowEngine.LOGGER.error( @@ -55,6 +58,7 @@ internal class UiAnalyticRectRenderer : UiSdfRenderer( for (page in fontAtlasLocations.indices) { GL20.glUniform1i(fontAtlasLocations[page], GlyphAtlasUnit + page) } + GL20.glUniform1f(glyphEdgeSoftnessLocation, GlyphEdgeSoftness) GL20.glUseProgram(previousProgram) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiBlurRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiBlurRenderer.kt index d241618ad..04560e06e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiBlurRenderer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiBlurRenderer.kt @@ -60,7 +60,7 @@ private fun renderBlurPass( opaqueSource: Boolean = false, ) { target.bind() - GL11.glViewport(0, 0, width, height) + RenderSystem.viewport(0, 0, width, height) configureBlurProjection(width.toFloat(), height.toFloat()) uiWriteAlpha(true) configureUiBlend() 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 13277e728..54aa5bf8a 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 @@ -1,5 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui.render +import com.mojang.blaze3d.systems.RenderSystem import org.lwjgl.opengl.GL11 import org.lwjgl.opengl.GL12 import org.lwjgl.opengl.GL30 @@ -273,7 +274,7 @@ internal class UiLayerFramebuffer( fun bind() { atlas.bind() - GL11.glViewport(region.x, region.y, region.width, region.height) + RenderSystem.viewport(region.x, region.y, region.width, region.height) } fun clear() { @@ -352,7 +353,7 @@ internal class UiFramebuffer( fun bind() { GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, framebuffer) - GL11.glViewport(0, 0, width, height) + RenderSystem.viewport(0, 0, width, height) } fun close() { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiImageShadowCache.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiImageShadowCache.kt index 6555841d0..3f6917566 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiImageShadowCache.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiImageShadowCache.kt @@ -253,7 +253,7 @@ private fun withImageShadowTarget(bounds: UiRect, draw: () -> Unit) { } finally { GL30.glBindFramebuffer(GL30.GL_READ_FRAMEBUFFER, read) GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, write) - GL11.glViewport(viewport[0], viewport[1], viewport[2], viewport[3]) + RenderSystem.viewport(viewport[0], viewport[1], viewport[2], viewport[3]) GL11.glScissor(clip[0], clip[1], clip[2], clip[3]) GL11.glClearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]) if (scissor) GL11.glEnable(GL11.GL_SCISSOR_TEST) else disableScissor() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiMsdfTextBatch.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiMsdfTextBatch.kt index f5f52fa50..cbf8480c0 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiMsdfTextBatch.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiMsdfTextBatch.kt @@ -10,6 +10,7 @@ import org.lwjgl.opengl.GL30 import ru.hollowhorizon.hollowengine.bridge.mixins.client.ShaderInstanceAccessor import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.ui.UiVec3 +import ru.hollowhorizon.hollowengine.client.ui.text.GlyphEdgeSoftness import ru.hollowhorizon.hollowengine.client.ui.text.UiGlyphAtlasPage import ru.hollowhorizon.hollowengine.client.ui.text.UiGlyphSampling import ru.hollowhorizon.hollowengine.common.registry.ModShaders @@ -83,7 +84,7 @@ internal class UiMsdfTextBatch : AutoCloseable { RenderSystem.setShader { shader } RenderSystem.setShaderTexture(0, current.texture) shader.safeGetUniform("DistanceRange")?.set(current.distanceRange) - shader.safeGetUniform("Softness")?.set(MsdfSoftness) + shader.safeGetUniform("Softness")?.set(GlyphEdgeSoftness - 1f) shader.safeGetUniform("AtlasSize")?.set(current.width, current.height) shader.safeGetUniform("GlyphMode")?.set(current.sampling.shaderMode.toFloat()) shader.safeGetUniform("SdBias")?.set(current.sdBias) @@ -160,8 +161,5 @@ internal class UiMsdfTextBatch : AutoCloseable { private companion object { const val VertexStride = 9 const val VertexStrideBytes = VertexStride * Float.SIZE_BYTES - - /** MSDF edge anti-aliasing softness (screen-space smoothing of the signed-distance edge). */ - const val MsdfSoftness = 0.15f } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiOffscreen.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiOffscreen.kt new file mode 100644 index 000000000..670c599e5 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiOffscreen.kt @@ -0,0 +1,39 @@ +package ru.hollowhorizon.hollowengine.client.ui.render + +import com.mojang.blaze3d.systems.RenderSystem +import org.joml.Matrix4f +import org.lwjgl.opengl.GL33 + +/** + * Runs [draw], which renders into a target of its own from inside a UI draw, with UI-state was + * drawing in set aside and put back afterward. + */ +internal inline fun withOwnRenderTarget(draw: () -> Unit) { + val read = GL33.glGetInteger(GL33.GL_READ_FRAMEBUFFER_BINDING) + val write = GL33.glGetInteger(GL33.GL_DRAW_FRAMEBUFFER_BINDING) + val viewport = IntArray(4).also { GL33.glGetIntegerv(GL33.GL_VIEWPORT, it) } + val scissorBox = IntArray(4).also { GL33.glGetIntegerv(GL33.GL_SCISSOR_BOX, it) } + val scissor = GL33.glIsEnabled(GL33.GL_SCISSOR_TEST) + val clearColor = FloatArray(4).also { GL33.glGetFloatv(GL33.GL_COLOR_CLEAR_VALUE, it) } + val alpha = uiBlendWritesAlpha + val projection = Matrix4f(RenderSystem.getProjectionMatrix()) + val sorting = RenderSystem.getVertexSorting() + val modelView = RenderSystem.getModelViewStack() + modelView.pushMatrix() + try { + disableScissor() + RenderSystem.colorMask(true, true, true, true) + draw() + } finally { + RenderSystem.setProjectionMatrix(projection, sorting) + modelView.popMatrix() + RenderSystem.applyModelViewMatrix() + GL33.glBindFramebuffer(GL33.GL_READ_FRAMEBUFFER, read) + GL33.glBindFramebuffer(GL33.GL_DRAW_FRAMEBUFFER, write) + RenderSystem.viewport(viewport[0], viewport[1], viewport[2], viewport[3]) + GL33.glScissor(scissorBox[0], scissorBox[1], scissorBox[2], scissorBox[3]) + if (scissor) GL33.glEnable(GL33.GL_SCISSOR_TEST) + GL33.glClearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]) + uiWriteAlpha(alpha) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextOverflowResolver.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextOverflowResolver.kt index e6dc2266e..fefeb5b8b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextOverflowResolver.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/render/UiTextOverflowResolver.kt @@ -40,7 +40,10 @@ internal object UiTextOverflowResolver { private var ellipsisStyle = UiInlineStyle.Empty private var ellipsisHeight = command.fontSize + private var originalEnd = Float.NaN + fun append(fragment: UiTextFragment): Boolean { + keepGapBefore(fragment) return when (fragment) { is UiTextRun -> appendText(fragment) is UiTextSpaceRun -> appendFixed(fragment, fragment.width) { it.copy(x = cursorX) }.also { @@ -52,6 +55,17 @@ internal object UiTextOverflowResolver { } } + private fun keepGapBefore(fragment: UiTextFragment) { + if (!originalEnd.isNaN()) { + val gap = fragment.x - originalEnd + if (gap > 0.01f) { + cursorX += gap + if (text.isNotEmpty() && text.last() != ' ') text.append(' ') + } + } + originalEnd = fragment.x + fragment.width + } + fun appendEllipsis() { val width = ellipsisWidth(ellipsisStyle) if (width > availableWidth + 0.01f) return diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowComposeUiScreen.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowComposeUiScreen.kt index 74fbae61d..600c44c70 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowComposeUiScreen.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowComposeUiScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import com.mojang.blaze3d.systems.RenderSystem import net.minecraft.client.gui.GuiGraphics import net.minecraft.client.gui.screens.Screen +import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.render.MinecraftUiRenderer import ru.hollowhorizon.hollowengine.client.ui.render.UiRenderTarget @@ -198,6 +199,11 @@ abstract class HollowComposeUiScreen( override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { if (closing) return false + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + pipeline.await() + if (surface.runtime.keyConsumed(keyCode, scanCode, modifiers)) return true + return super.keyPressed(keyCode, scanCode, modifiers) + } if (super.keyPressed(keyCode, scanCode, modifiers)) return true pipeline.await() return surface.runtime.keyPressed(keyCode, scanCode, modifiers) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiTextLabDemo.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiTextLabDemo.kt index 8bfbafb62..a6d4efce7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiTextLabDemo.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowUiTextLabDemo.kt @@ -341,7 +341,7 @@ private class TextLabArgs(private val effect: TextLabEffect, private val values: fun int(index: Int): Int = number(index).toInt() - /** Blank means "no override": the rule then takes the colour the text is drawn in. */ + /** Blank means "no override": the rule then takes the color the text is drawn in. */ fun color(index: Int): UiColor? { val raw = values[effect.key(index)]?.trim().orEmpty().ifEmpty { effect.params[index].default } return parseHexColor(raw) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowWaitScreen.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowWaitScreen.kt new file mode 100644 index 000000000..017337253 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/screen/HollowWaitScreen.kt @@ -0,0 +1,29 @@ +package ru.hollowhorizon.hollowengine.client.ui.screen + +import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.style.MinecraftHssResourceLoader + +/** + * Covers the game while something else drives it for a moment, such as a tool moving the camera to + * take a picture, so the player neither sees the jumps nor steers into them. Its owner closes it: Esc + * does not, and the game keeps running underneath. + */ +class HollowWaitScreen(private val heading: String, private val message: String) : + HollowComposeUiScreen(heading, MinecraftHssResourceLoader.load(STYLESHEET)) { + + @Composable + override fun Content() { + Column(id = "wait-screen", tags = listOf("wait-screen")) { + Text(heading, tags = listOf("wait-heading")) + Text(message, tags = listOf("wait-message")) + } + } + + override fun shouldCloseOnEsc(): Boolean = false + + private companion object { + const val STYLESHEET = "hollowengine:ui/styles/wait-screen.hss" + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptScreen.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptScreen.kt index e35ac2ead..8d9322d27 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptScreen.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/script/UiScriptScreen.kt @@ -36,9 +36,8 @@ class UiScriptScreen( sessionId?.let { UiScriptClient.send(it, payload) } } - override fun close() { - mc.setScreen(null) - } + /** The same exit Escape takes, so a script closing itself still plays its exit animation. */ + override fun close() = onClose() @Composable override fun Content() { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiLayoutScroll.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiLayoutScroll.kt index a90d9f033..9b2ea9eb5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiLayoutScroll.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiLayoutScroll.kt @@ -37,6 +37,9 @@ internal fun applyScrollRanges( internal class ScrollbarCache { private val byContainer = WeakHashMap>() + fun hoverProgress(container: UiNode, orientation: ScrollbarOrientation, nowNanos: Long): Float = + byContainer[container]?.get(orientation)?.hoverProgress(nowNanos) ?: 0f + fun scrollbar(container: UiNode, orientation: ScrollbarOrientation): ScrollbarNode { val map = byContainer.getOrPut(container) { mutableMapOf() } return map.getOrPut(orientation) { @@ -52,23 +55,27 @@ internal fun placeScrollbarNodes( ): Map> { var scrollbars: HashMap>? = null var additions: ArrayList? = null + var easing: ArrayList? = null + val now = System.nanoTime() for ((container, containerLayout) in layouts) { val spec = container.scrollSpec() ?: continue val style = container.resolvedSnapshot - val geometry = scrollbarGeometry(spec, style, containerLayout) + val geometry = scrollbarGeometry(spec, style, containerLayout) { cache.hoverProgress(container, it, now) } if (geometry.isEmpty()) continue val bars = ArrayList(geometry.size) val pending = additions ?: ArrayList().also { additions = it } for (geom in geometry) { val bar = cache.scrollbar(container, geom.orientation) - bar.applyPartStyles(style.scrollbar) + bar.applyPartStyles(style.scrollbar, bar.hoverProgress(now)) pending += scrollbarPartLayout(bar, geom.track, containerLayout) pending += scrollbarPartLayout(bar.thumb, geom.thumb, containerLayout) bars += bar + if (bar.isEasing(now)) (easing ?: ArrayList().also { easing = it }) += bar } (scrollbars ?: HashMap>().also { scrollbars = it })[container] = bars } additions?.forEach { layouts[it.node] = it } + easing?.forEach { it.layoutState.invalidateLayout() } return scrollbars ?: emptyMap() } @@ -96,11 +103,12 @@ private fun scrollbarGeometry( spec: UiScrollSpec, style: UiComputedStyle, layoutNode: UiLayoutNode, + hover: (ScrollbarOrientation) -> Float, ): List { val rect = layoutNode.rect val scrollArea = layoutNode.scrollArea - val vertical = style.scrollbar.resolved(scrollArea.width) - val horizontal = style.scrollbar.resolved(scrollArea.height) + val vertical = style.scrollbar.resolved(scrollArea.width, hover(ScrollbarOrientation.VERTICAL)) + val horizontal = style.scrollbar.resolved(scrollArea.height, hover(ScrollbarOrientation.HORIZONTAL)) val hasVertical = spec.verticalScrollbar && vertical.isVisible && layoutNode.scrollRange.y > ScrollOverflowEpsilon && scrollArea.height > vertical.thickness val hasHorizontal = spec.horizontalScrollbar && horizontal.isVisible && @@ -112,7 +120,8 @@ private fun scrollbarGeometry( val trackLength = scrollArea.height - vertical.margin * 2f - if (hasHorizontal) horizontal.gutter else 0f if (trackLength > 0f) { val track = UiRect( - x = rect.width - vertical.thickness - vertical.margin, + x = if (vertical.atStart) scrollArea.x - rect.x + vertical.margin + else rect.width - vertical.thickness - vertical.margin, y = scrollArea.y - rect.y + vertical.margin, width = vertical.thickness, height = trackLength, @@ -131,7 +140,8 @@ private fun scrollbarGeometry( if (trackLength > 0f) { val track = UiRect( x = scrollArea.x - rect.x + horizontal.margin, - y = rect.height - horizontal.thickness - horizontal.margin, + y = if (horizontal.atStart) scrollArea.y - rect.y + horizontal.margin + else rect.height - horizontal.thickness - horizontal.margin, width = trackLength, height = horizontal.thickness, ) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollHandle.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollHandle.kt index 2c1361104..5f213f258 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollHandle.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollHandle.kt @@ -1,7 +1,10 @@ package ru.hollowhorizon.hollowengine.client.ui.scroll import androidx.compose.runtime.* +import ru.hollowhorizon.hollowengine.client.ui.UiMatrix4 +import ru.hollowhorizon.hollowengine.client.ui.UiVec3 import ru.hollowhorizon.hollowengine.client.ui.layout.UiRect +import kotlin.math.hypot /** * The scroll position of one scroll container. @@ -16,6 +19,21 @@ class UiScrollHandle { var viewport: UiRect by mutableStateOf(UiRect.Zero) internal set + /** From the container to the screen, through every transform above it; see [toScreen]. */ + internal var screenTransform: UiMatrix4 = UiMatrix4.identity() + internal var contentOffsetX = 0f + internal var contentOffsetY = 0f + + fun toScreen(x: Float, y: Float): UiVec3 = screenTransform.transform(contentOffsetX + x, contentOffsetY + y) + + /** How many screen pixels one unit of the content spans, which a transform above can change. */ + val screenScale: Float + get() { + val origin = toScreen(0f, 0f) + val unit = toScreen(1f, 0f) + return hypot(unit.x - origin.x, unit.y - origin.y).takeIf { it > 0f } ?: 1f + } + /** How far each axis can still travel: the content size minus the viewport, never negative. */ var range: UiScrollOffset by mutableStateOf(UiScrollOffset.Zero) internal set diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbar.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbar.kt index 78dcc1ddf..2099d50d1 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbar.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbar.kt @@ -19,6 +19,33 @@ class ScrollbarNode( ) : BaseUiNode(UiScrollbarType, id = null, tags = listOf(orientation.tagName)) { val thumb: ScrollbarThumbNode = ScrollbarThumbNode(orientation) + /** + * Under the pointer or being dragged, set by the input controller. + */ + internal var engaged: Boolean = false + set(value) { + if (field == value) return + val now = System.nanoTime() + progressAtChange = hoverProgress(now) + changedAtNanos = now + field = value + layoutState.invalidateLayout() + } + + private var progressAtChange = 0f + private var changedAtNanos = 0L + + /** How far into its hover look the scrollbar is at [nowNanos], eased, 0 at rest and 1 engaged. */ + internal fun hoverProgress(nowNanos: Long): Float { + val target = if (engaged) 1f else 0f + val t = ((nowNanos - changedAtNanos) / HoverTransitionNanos).coerceIn(0f, 1f) + val eased = 1f - (1f - t) * (1f - t) + return progressAtChange + (target - progressAtChange) * eased + } + + /** Whether [hoverProgress] is still on its way, so the next frame has to lay the bar out again. */ + internal fun isEasing(nowNanos: Long): Boolean = nowNanos - changedAtNanos < HoverTransitionNanos + private var appliedTrack: UiScrollbarPartStyle? = null private var appliedThumb: UiScrollbarPartStyle? = null @@ -30,19 +57,29 @@ class ScrollbarNode( } /** Re-resolves the two part styles, skipping the work while the container's styling is unchanged. */ - internal fun applyPartStyles(style: UiScrollbarStyle) { + internal fun applyPartStyles(style: UiScrollbarStyle, hover: Float) { if (appliedTrack != style.track) { appliedTrack = style.track resolvedSnapshot = ScrollbarDefaultStyles.resolvePart(style.track, ScrollbarDefaultStyles.TrackPaint, false) } - if (appliedThumb != style.thumb) { - appliedThumb = style.thumb + val hoverPaint = style.thumbHover.paint + val thumbPart = when { + hover <= 0f || hoverPaint == null -> style.thumb + hover >= 1f -> style.thumb.merge(style.thumbHover) + else -> style.thumb.copy( + paint = interpolatePaint(style.thumb.paint ?: ScrollbarDefaultStyles.ThumbPaint, hoverPaint, hover), + ) + } + if (appliedThumb != thumbPart) { + appliedThumb = thumbPart thumb.resolvedSnapshot = - ScrollbarDefaultStyles.resolvePart(style.thumb, ScrollbarDefaultStyles.ThumbPaint, true) + ScrollbarDefaultStyles.resolvePart(thumbPart, ScrollbarDefaultStyles.ThumbPaint, true) } } } +private const val HoverTransitionNanos = 90_000_000f + class ScrollbarThumbNode( val orientation: ScrollbarOrientation, ) : BaseUiNode(UiScrollbarThumbType, id = null, tags = listOf(orientation.tagName)) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbarInput.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbarInput.kt index 26eea964e..aa185e9c0 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbarInput.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/scroll/UiScrollbarInput.kt @@ -12,6 +12,7 @@ import ru.hollowhorizon.hollowengine.client.ui.scrollHandle */ internal data class UiScrollbarDragState( val node: UiNode, + val scrollbar: ScrollbarNode, val handle: UiScrollHandle, val orientation: ScrollbarOrientation, val track: UiRect, @@ -55,7 +56,7 @@ internal fun scrollbarThumbDragState( } } -/** Pressing the track centres the thumb on the pointer, then tracks it like a thumb drag would. */ +/** Pressing the track centers the thumb on the pointer, then tracks it like a thumb drag would. */ internal fun scrollbarTrackDragState( layouts: Map, scrollbar: ScrollbarNode, @@ -86,6 +87,7 @@ private inline fun scrollbarDragState( val local = inverse.transform(mouseX, mouseY, 0f) return UiScrollbarDragState( node = container, + scrollbar = scrollbar, handle = handle, orientation = scrollbar.orientation, track = trackRel, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPathGeometry.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPathGeometry.kt index 4ec5409ad..2d946740e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPathGeometry.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiPathGeometry.kt @@ -367,7 +367,7 @@ private fun addJoin( } } -/** Round/square/butt cap centred on [end], the outer terminal of a segment coming from [inner]. */ +/** Round/square/butt cap centered on [end], the outer terminal of a segment coming from [inner]. */ private fun addCap( inner: UiPathPoint, end: UiPathPoint, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgStyleParser.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgStyleParser.kt index 4a7fb4620..cad9ce39e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgStyleParser.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/shape/UiSvgStyleParser.kt @@ -320,7 +320,7 @@ private val namedSvgColors = mapOf( "cyan" to UiColor(0f, 1f, 1f, 1f), "magenta" to UiColor(1f, 0f, 1f, 1f), "gray" to UiColor(0.5f, 0.5f, 0.5f, 1f), - "grey" to UiColor(0.5f, 0.5f, 0.5f, 1f), + "gray" to UiColor(0.5f, 0.5f, 0.5f, 1f), "orange" to UiColor(1f, 0.647f, 0f, 1f), "purple" to UiColor(0.5f, 0f, 0.5f, 1f), ) 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 d321dbebf..0f24f4883 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 @@ -9,6 +9,37 @@ import ru.hollowhorizon.hollowengine.client.ui.hasEffectiveStates data class HssDocument( val rules: List, val keyframes: List = emptyList(), + val imports: List = emptyList(), + val variables: List = emptyList(), + val sets: List = emptyList(), +) + +/** + * `$name { declarations }` at the top of a stylesheet: declarations a rule takes in with + * `@apply $name;`, the way several rules share one look (a scrollbar, a popup frame). + */ +data class HssDeclarationSet( + val name: String, + val declarations: List, + val nameStart: Int = -1, +) + +/** `@import "namespace:path.hss";`: the variables and rules of another stylesheet, taken in first. */ +data class HssImport( + val location: String, + val start: Int = -1, + val end: Int = -1, +) + +/** + * `$name: value;` at the top of a stylesheet. Any value, a variable's included, may name it as + * `$name`, and the name is replaced by the value before the declaration is compiled. + */ +data class HssVariable( + val name: String, + val value: String, + val nameStart: Int = -1, + val valueStart: Int = -1, ) data class HssRule( @@ -33,6 +64,12 @@ data class HssDeclaration( private fun rangeAt(start: Int, length: Int): IntRange? = if (start < 0) null else start until (start + length).coerceAtLeast(start + 1) + + val isApply: Boolean get() = property == APPLY + + companion object { + const val APPLY = "@apply" + } } data class HssKeyframes( 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 a3421c68f..30bd54a58 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 @@ -160,7 +160,17 @@ internal val HssDeclaration.canonicalProperty: String get() = HssSchema.find(property)?.name ?: property.lowercase() internal fun compileHss(source: String, origin: StyleOrigin = StyleOrigin.STYLESHEET): CompiledHss = - HssCompiler(origin).compile(parseHss(source)) + compileHss(parseHss(source), origin) + +/** Compiles [document] with its imports read and its variables written in. */ +internal fun compileHss( + document: HssDocument, + origin: StyleOrigin = StyleOrigin.STYLESHEET, + scope: HssScope = HssScope.of(document), +): CompiledHss { + scope.failures.firstOrNull()?.let { (import, reason) -> throw HssParseException(reason, import.start, import.end) } + return HssCompiler(origin).compile(document.resolved(scope)) +} /** Compiles a single `name: value` pair, as used by HSS-valued node attributes. */ internal fun compileStyleModifier(property: String, value: String): Modifier? = diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssImports.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssImports.kt new file mode 100644 index 000000000..150198eeb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssImports.kt @@ -0,0 +1,127 @@ +package ru.hollowhorizon.hollowengine.client.ui.style + +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.client.ui.HollowUiResourceAccess + +/** Reads the text of a stylesheet an `@import` names, or null when there is none. */ +fun interface HssImportReader { + fun read(location: String): String? +} + +/** + * Where imported stylesheets come from: the resource packs and the project folder, the way every + * stylesheet is read, and the classpath when there is no game around (tests, tools). + */ +object HssImports { + var reader: HssImportReader = HssImportReader { location -> + val id = ResourceLocation.tryParse(location) ?: return@HssImportReader null + runCatching { HollowUiResourceAccess.readText(id) }.getOrNull() + ?: HssImports::class.java.getResourceAsStream("/assets/${id.namespace}/${id.path}") + ?.bufferedReader()?.use { it.readText() } + } +} + +/** A `$name` inside a value. */ +internal val HssVariableReference = Regex("""\$([A-Za-z_][A-Za-z0-9_-]*)""") + +/** + * What a stylesheet sees once its imports are read: every variable in scope, and the rules and + * keyframes the imports bring. Imports are read depth first and each only once, so two sheets that + * import the same palette, or a palette imported in a cycle, are not read twice. + */ +class HssScope internal constructor( + val variables: Map, + val sets: Map, + val importedRules: List, + val importedKeyframes: List, + val imported: List, + val failures: List>, +) { + /** + * [value] with every variable written in, variables inside variables included. A name that is + * not in scope, or that refers back to itself, is left as it is and fails where it is used. + */ + fun substitute(value: String): String = substitute(value, HashSet()) + + /** + * [declarations] with every `@apply $set;` replaced by the set's declarations, sets inside sets + * included, and every value with its variables written in. + */ + fun expand(declarations: List): List = expand(declarations, HashSet()) + + private fun expand(declarations: List, applying: MutableSet): List = + declarations.flatMap { declaration -> + if (!declaration.isApply) return@flatMap listOf(declaration.copy(value = substitute(declaration.value))) + val name = declaration.value.trim().removePrefix("$") + val set = sets[name] ?: return@flatMap emptyList() + if (!applying.add(name)) return@flatMap emptyList() + expand(set.declarations, applying).also { applying.remove(name) } + } + + private fun substitute(value: String, visiting: MutableSet): String { + if ('$' !in value) return value + return HssVariableReference.replace(value) { match -> + val name = match.groupValues[1] + val variable = variables[name] + if (variable == null || !visiting.add(name)) return@replace match.value + substitute(variable.value, visiting).also { visiting.remove(name) } + } + } + + companion object { + /** The scope of [document], reading its imports with [reader]. */ + fun of(document: HssDocument, reader: HssImportReader = HssImports.reader): HssScope { + val variables = LinkedHashMap() + val sets = LinkedHashMap() + val rules = ArrayList() + val keyframes = ArrayList() + val read = LinkedHashSet() + val failures = ArrayList>() + + fun visit(imports: List) { + for (import in imports) { + if (!read.add(import.location)) continue + val text = reader.read(import.location) + if (text == null) { + failures += import to "No stylesheet '${import.location}'" + continue + } + val imported = try { + parseHss(text) + } catch (error: HssParseException) { + failures += import to "'${import.location}' does not parse: ${error.messageText}" + continue + } + visit(imported.imports) + imported.variables.forEach { variables[it.name] = HssScopedVariable(it.name, it.value, import.location) } + imported.sets.forEach { sets[it.name] = it } + rules += imported.rules + keyframes += imported.keyframes + } + } + + visit(document.imports) + document.variables.forEach { variables[it.name] = HssScopedVariable(it.name, it.value, null) } + document.sets.forEach { sets[it.name] = it } + return HssScope(variables, sets, rules, keyframes, read.toList(), failures) + } + } +} + +/** A variable in scope, and the stylesheet it came from; null for the stylesheet itself. */ +data class HssScopedVariable(val name: String, val value: String, val source: String?) + +/** + * The document ready to compile: the imported rules first, as if they were written at the top, and + * every value with its variables written in. + */ +fun HssDocument.resolved(scope: HssScope = HssScope.of(this)): HssDocument { + val imported = scope.importedRules.mapIndexed { index, rule -> rule.copy(order = index) } + val own = rules.map { rule -> rule.copy(order = imported.size + rule.order) } + return HssDocument( + rules = (imported + own).map { rule -> rule.copy(declarations = scope.expand(rule.declarations)) }, + keyframes = (scope.importedKeyframes + keyframes).map { keyframes -> + keyframes.copy(frames = keyframes.frames.map { frame -> frame.copy(declarations = scope.expand(frame.declarations)) }) + }, + ) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssParser.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssParser.kt index 2bf7fb375..ad120cb12 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssParser.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssParser.kt @@ -36,11 +36,22 @@ class HssParser(private val source: String) { private fun parseDocument(): HssDocument { val rules = mutableListOf() val keyframes = mutableListOf() + val imports = mutableListOf() + val variables = mutableListOf() + val sets = mutableListOf() skipIgnored() while (!isEnd()) { val start = index try { - if (peek() == '@') keyframes += parseAtRule() else rules += parseRule() + when { + peekAhead("@import") -> imports += parseImport() + peek() == '@' -> keyframes += parseAtRule() + peek() == '$' -> when (val parsed = parseVariableOrSet()) { + is HssVariable -> variables += parsed + is HssDeclarationSet -> sets += parsed + } + else -> rules += parseRule() + } } catch (exception: HssParseException) { if (!recovering) throw exception errors += exception @@ -49,7 +60,48 @@ class HssParser(private val source: String) { if (index == start) index++ skipIgnored() } - return HssDocument(rules, keyframes) + return HssDocument(rules, keyframes, imports, variables, sets) + } + + private fun parseImport(): HssImport { + val start = index + expect('@') + readIdentifier() + skipIgnored() + val quote = if (isEnd()) ' ' else peek() + if (quote != '"' && quote != '\'') throw HssParseException("Expected the quoted stylesheet to import", start, index + 1) + val valueStart = ++index + while (!isEnd() && peek() != quote) index++ + if (isEnd()) throw HssParseException("Unterminated import", valueStart - 1, index) + val location = source.substring(valueStart, index) + index++ + endStatement() + return HssImport(location, valueStart, valueStart + location.length) + } + + /** `$name: value;` is a variable, `$name { … }` a set of declarations. */ + private fun parseVariableOrSet(): Any { + val start = index + expect('$') + val name = readIdentifier() + val token = HssToken("$" + name.text, start, name.end) + skipIgnored() + if (!isEnd() && peek() == '{') { + expect('{') + val declarations = parseDeclarations() + expect('}') + return HssDeclarationSet(name.text, declarations, start) + } + expect(':', "Expected ':' or '{' after '${token.text}'", token) + val value = readDeclarationValue(token) + endStatement() + return HssVariable(name.text, value.text, start, value.start) + } + + /** A top-level statement may end in `;`, which is optional before the next one. */ + private fun endStatement() { + skipIgnored() + if (!isEnd() && peek() == ';') index++ } private fun parseRule(): HssRule { @@ -226,6 +278,12 @@ class HssParser(private val source: String) { } private fun parseDeclaration(): HssDeclaration { + if (peekAhead(HssDeclaration.APPLY)) { + val start = index + index += HssDeclaration.APPLY.length + val value = readDeclarationValue(HssToken(HssDeclaration.APPLY, start, index)) + return HssDeclaration(HssDeclaration.APPLY, value.text, start, value.start) + } val property = readPropertyName() expect(':', "Expected ':' after '${property.text}'", property) val value = readDeclarationValue(property) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssSyntax.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssSyntax.kt index 8dfeabd77..f90fa790d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssSyntax.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssSyntax.kt @@ -12,7 +12,7 @@ enum class HssValueKind(val display: String, val candidates: List = empt PIXELS("px", listOf("0px", "2px", "4px", "8px", "16px")), NUMBER("number", listOf("0", "0.5", "1")), INTEGER("int", listOf("0", "1", "2")), - COLOR("color", listOf("#FFFFFF", "#000000", "transparent", "white", "black", "rgba(255, 255, 255, 1)")), + COLOR("color", listOf("#FFFFFF", "#000000", "transparent", "white", "black", "rgba(255, 255, 255, 1)", "alpha(#FFFFFF, 0.5)")), PAINT( "paint", listOf( diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssTextProperties.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssTextProperties.kt index fc89951bf..82cd3625d 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssTextProperties.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssTextProperties.kt @@ -90,13 +90,13 @@ internal fun textHssProperties(): List = hssProperties { property( "caret-color", "text-field-caret", - summary = "Colour of the text-field caret.", + summary = "Color of the text-field caret.", syntax = colorSyntax("color"), ) { style { it.textField = it.textFieldStyle().copy(caretColor = parseColor(value)) } } property( "selection-color", "text-selection", - summary = "Colour of the text-field selection highlight.", + summary = "Color of the text-field selection highlight.", syntax = colorSyntax("color"), ) { style { it.textField = it.textFieldStyle().copy(selectionColor = parseColor(value)) } } @@ -114,7 +114,7 @@ internal fun textHssProperties(): List = hssProperties { property( "line-number-color", - summary = "Colour of the code-editor line numbers.", + summary = "Color of the code-editor line numbers.", syntax = colorSyntax("color"), ) { style { it.textField = it.textFieldStyle().copy(lineNumberColor = parseColor(value)) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssValues.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssValues.kt index 90d3f5590..314433311 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssValues.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssValues.kt @@ -269,6 +269,13 @@ private fun parseGradientStops(args: List): List { fun parseColor(value: String): UiColor { val cleaned = value.trim() + if (cleaned.startsWith("alpha(")) { + val args = functionArgs(cleaned, "alpha") + require(args.size == 2) { "Expected alpha(color, opacity), got '$value'" } + val base = parseColor(args[0]) + val opacity = args[1].trim().let { if (it.endsWith("%")) it.dropLast(1).toFloat() / 100f else it.toFloat() } + return UiColor(base.red, base.green, base.blue, base.alpha * opacity) + } if (cleaned.startsWith("rgba(")) { val args = functionArgs(cleaned, "rgba").map { it.trim().toFloat() } return UiColor(args[0] / 255f, args[1] / 255f, args[2] / 255f, args.getOrElse(3) { 1f }) @@ -355,11 +362,11 @@ internal fun parseMask(value: String): UiFilterEffect.LinearMask? { */ private fun parseLinearMask(args: String): UiFilterEffect.LinearMask { val parts = splitTopLevel(args, ',').map { it.trim() }.filter { it.isNotEmpty() } - require(parts.isNotEmpty()) { "linear-gradient() needs at least one colour stop" } + require(parts.isNotEmpty()) { "linear-gradient() needs at least one color stop" } val angle = parseGradientAngle(parts.first()) val stopParts = if (angle != null) parts.drop(1) else parts - require(stopParts.isNotEmpty()) { "linear-gradient() needs at least one colour stop" } + require(stopParts.isNotEmpty()) { "linear-gradient() needs at least one color stop" } val positions = arrayOfNulls(stopParts.size) val alphas = FloatArray(stopParts.size) @@ -616,6 +623,7 @@ internal fun looksLikeColor(value: String): Boolean { val cleaned = value.trim().lowercase() return cleaned.startsWith("rgba(") || cleaned.startsWith("rgb(") || + cleaned.startsWith("alpha(") || cleaned.startsWith("#") || cleaned == "transparent" || cleaned == "white" || diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssVisualProperties.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssVisualProperties.kt index e676d13a3..a107e2f23 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssVisualProperties.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssVisualProperties.kt @@ -1,6 +1,7 @@ package ru.hollowhorizon.hollowengine.client.ui.style import ru.hollowhorizon.hollowengine.client.ui.UiBorder +import ru.hollowhorizon.hollowengine.client.ui.UiBorderDash import ru.hollowhorizon.hollowengine.client.ui.UiInsets import ru.hollowhorizon.hollowengine.client.ui.UiLength import ru.hollowhorizon.hollowengine.client.ui.UiTransform @@ -14,7 +15,7 @@ private fun colorSlot(name: String = "color") = slot(name, HssValueKind.COLOR) internal fun visualHssProperties(): List = hssProperties { property( "background", - summary = "Fill drawn behind the node: colour, image, shader or gradient.", + summary = "Fill drawn behind the node: color, image, shader or gradient.", syntax = syntax(paintSlot()), examples = listOf("#1E2128", "transparent", "image(\"\")", "linear-gradient(180deg, #000000, #FFFFFF)"), ) { set(UiProps.Background, parsePaint(value)) } @@ -28,7 +29,7 @@ internal fun visualHssProperties(): List = hssProperties { property( "foreground", "color", - summary = "Text and icon colour; inherited by children.", + summary = "Text and icon color; inherited by children.", syntax = syntax(colorSlot()), examples = listOf("#FFFFFF", "#cbd3df", "rgba(255, 255, 255, 0.7)"), ) { style { it.foreground = parseColor(value) } } @@ -66,7 +67,7 @@ internal fun visualHssProperties(): List = hssProperties { property( "border-color", - summary = "Border fill: a colour, or a gradient stroked around the node.", + summary = "Border fill: a color, or a gradient stroked around the node.", syntax = syntax(paintSlot("color")), examples = listOf("#5F6677", "linear-gradient(120deg, #9BF763, #70A832)"), ) { style { it.borderPaint = parsePaint(value) } } @@ -82,6 +83,20 @@ internal fun visualHssProperties(): List = hssProperties { borderEdge("border-bottom", "bottom") { insets, width -> insets.copy(bottom = width) } borderEdge("border-left", "left") { insets, width -> insets.copy(left = width) } + property( + "border-dash", + summary = "Dashes the border: dash length, then the gap after it. A zero length draws it solid.", + syntax = syntax( + slot("dash", HssValueKind.PIXELS), + slot("gap", HssValueKind.PIXELS, optional = true), + ), + examples = listOf("4px 3px", "6px", "0px"), + ) { + val parts = value.trim().split(Regex("\\s+")) + val length = parseScalar(parts[0]) + set(UiProps.BorderDash, UiBorderDash(length, parts.getOrNull(1)?.let(::parseScalar) ?: length)) + } + property( "border-radius", summary = "Corner radius of the background, border and clip.", @@ -112,7 +127,7 @@ internal fun visualHssProperties(): List = hssProperties { property( "tint", - summary = "Colour multiplied into everything the node draws; stacks across states.", + summary = "Color multiplied into everything the node draws; stacks across states.", syntax = syntax(colorSlot("tint")), examples = listOf("#FFFFFF", "rgba(255, 255, 255, 0.75)"), ) { set(UiProps.Tint, parseColor(value)) } @@ -318,8 +333,7 @@ private fun HssPropertyBuilder.axisProperty( } /** - * Declares a single-edge border. The width is per edge, the colour is shared by the whole - * border — the engine keeps one border colour — so the last declared colour wins. + * Declares a single-edge border. The width is per edge, the color is shared by the whole border. */ private fun HssPropertyBuilder.borderEdge( name: String, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssWidgetProperties.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssWidgetProperties.kt index 63f2bf471..1384d8f66 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssWidgetProperties.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/HssWidgetProperties.kt @@ -41,6 +41,31 @@ internal fun widgetHssProperties(): List = hssProperties { syntax = syntax(slot("overlay", HssValueKind.BOOLEAN)), ) { style { it.scrollbar = it.scrollbarStyle().copy(overlay = parseBoolean(value)) } } + property( + "scrollbar-hover-thickness", + summary = "Thickness an overlay scrollbar grows to under the pointer or while dragged.", + syntax = syntax(sizeSlot("thickness", auto = false)), + examples = listOf("6px", "8px"), + ) { style { it.scrollbar = it.scrollbarStyle().copy(hoverThickness = parseLength(value)) } } + + property( + "scrollbar-thumb-hover", + summary = "Fill of the scrollbar thumb under the pointer or while dragged.", + syntax = syntax(slot("paint", HssValueKind.PAINT)), + ) { + style { patch -> + val current = patch.scrollbarStyle() + patch.scrollbar = current.copy(thumbHover = current.thumbHover.copy(paint = parsePaint(value))) + } + } + + property( + "scrollbar-side", + summary = "Edge an overlay scrollbar sits on: `end` (right, bottom) or `start` (left, top).", + syntax = syntax(keywordSlot("side", "start", "end")), + examples = listOf("start", "end"), + ) { style { it.scrollbar = it.scrollbarStyle().copy(atStart = value.trim() == "start") } } + property( "scrollbar-track", summary = "Fill of the scrollbar track.", @@ -179,7 +204,7 @@ internal fun widgetHssProperties(): List = hssProperties { property( "checkbox-mark", - summary = "Colour of the check mark.", + summary = "Color of the check mark.", syntax = syntax(slot("paint", HssValueKind.PAINT)), ) { style { it.checkbox = (it.checkbox ?: UiCheckboxStyle()).copy(markPaint = parsePaint(value)) } } 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 6d2bd535b..a86c14269 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 @@ -117,7 +117,7 @@ class UiModifierResolver( 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 (colour/opacity) leaves the fingerprint and the layout untouched. + // it, while a draw-only animation (color/opacity) leaves the fingerprint and the layout untouched. node.layoutState.updateResolvedLayoutFingerprint(finalStyle.layoutFingerprint()) val modifierRefresh = finalStyle.requiresModifierRefresh() if (!requiresRefresh && modifierRefresh) requiresRefresh = 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 2e587b910..786bdcdde 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 @@ -120,6 +120,7 @@ object UiProps { interpolate = ::interpolatePaint, ) val BorderRadius = prop("border-radius", 0f) + val BorderDash = prop("border-dash", null) // Visuals val Background = prop( @@ -265,13 +266,15 @@ var UiStylePatch.border: UiBorder? val width = this[UiProps.BorderWidth] val paint = this[UiProps.BorderPaint] val radius = this[UiProps.BorderRadius] - if (width == null && paint == null && radius == null) return null - return UiBorder(width ?: UiInsets.Zero, paint ?: UiPaint.None, radius ?: 0f) + val dash = this[UiProps.BorderDash] + if (width == null && paint == null && radius == null && dash == null) return null + return UiBorder(width ?: UiInsets.Zero, paint ?: UiPaint.None, radius ?: 0f, dash) } set(value) { this[UiProps.BorderWidth] = value?.width this[UiProps.BorderPaint] = value?.paint this[UiProps.BorderRadius] = value?.radius + this[UiProps.BorderDash] = value?.dash } var UiStylePatch.background by UiProps.Background var UiStylePatch.foreground by UiProps.Foreground @@ -397,7 +400,12 @@ val UiComputedStyle.grow by UiProps.Grow val UiComputedStyle.position by UiProps.Position val UiComputedStyle.border: UiBorder - get() = UiBorder(this[UiProps.BorderWidth], this[UiProps.BorderPaint], this[UiProps.BorderRadius]) + get() = UiBorder( + this[UiProps.BorderWidth], + this[UiProps.BorderPaint], + this[UiProps.BorderRadius], + this[UiProps.BorderDash], + ) val UiComputedStyle.background by UiProps.Background val UiComputedStyle.foreground by UiProps.Foreground val UiComputedStyle.image by UiProps.Image 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 b8eda7b24..9eceac20f 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 @@ -190,23 +190,32 @@ data class UiScrollbarStyle( val margin: UiLength? = null, val minThumbSize: UiLength? = null, val overlay: Boolean? = null, + val atStart: Boolean? = null, val track: UiScrollbarPartStyle = UiScrollbarPartStyle(), val thumb: UiScrollbarPartStyle = UiScrollbarPartStyle(), + val thumbHover: UiScrollbarPartStyle = UiScrollbarPartStyle(), + val hoverThickness: UiLength? = null, ) { fun merge(other: UiScrollbarStyle): UiScrollbarStyle = UiScrollbarStyle( thickness = other.thickness ?: thickness, margin = other.margin ?: margin, minThumbSize = other.minThumbSize ?: minThumbSize, overlay = other.overlay ?: overlay, + atStart = other.atStart ?: atStart, track = track.merge(other.track), thumb = thumb.merge(other.thumb), + thumbHover = thumbHover.merge(other.thumbHover), + hoverThickness = other.hoverThickness ?: hoverThickness, ) - fun resolved(reference: Float): ResolvedUiScrollbarStyle { - val resolvedThickness = (thickness ?: DefaultThickness).resolve(reference).coerceAtLeast(0f) - if (resolvedThickness <= 0f) return HiddenScrollbar + fun resolved(reference: Float, hover: Float = 0f): ResolvedUiScrollbarStyle { + val base = (thickness ?: DefaultThickness).resolve(reference).coerceAtLeast(0f) + if (base <= 0f) return HiddenScrollbar + val full = hoverThickness?.takeIf { hover > 0f && overlay == true }?.resolve(reference)?.coerceAtLeast(base) + val resolvedThickness = full?.let { base + (it - base) * hover.coerceIn(0f, 1f) } ?: base return ResolvedUiScrollbarStyle( overlay = overlay == true, + atStart = overlay == true && atStart == true, thickness = resolvedThickness, margin = (margin ?: DefaultMargin).resolve(reference).coerceAtLeast(0f), minThumbSize = (minThumbSize ?: DefaultMinThumbSize).resolve(reference).coerceAtLeast(1f), @@ -222,6 +231,7 @@ data class UiScrollbarStyle( private val HiddenScrollbar = ResolvedUiScrollbarStyle( overlay = false, + atStart = false, thickness = 0f, margin = 0f, minThumbSize = 1f, @@ -249,6 +259,7 @@ data class UiScrollbarPartStyle( data class ResolvedUiScrollbarStyle( val overlay: Boolean, + val atStart: Boolean, val thickness: Float, val margin: Float, val minThumbSize: Float, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStylesheetReference.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStylesheetReference.kt index f55061d33..e05515144 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStylesheetReference.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/style/UiStylesheetReference.kt @@ -56,9 +56,17 @@ sealed interface UiStylesheetReference { object MinecraftHssResourceLoader : HssResourceLoader { private val locations = ConcurrentHashMap() private val stylesheets = ConcurrentHashMap() + + /** What each stylesheet imported when it was last compiled, so editing an import reloads it too. */ + private val imports = ConcurrentHashMap>() + private val source = object : HssResourceLoader { - override fun load(location: String): CompiledHss = - compileHss(HollowUiResourceAccess.readText(resourceLocation(location))) + override fun load(location: String): CompiledHss { + val document = parseHss(HollowUiResourceAccess.readText(resourceLocation(location))) + val scope = HssScope.of(document) + imports[location] = scope.imported + return compileHss(document, scope = scope) + } override fun version(location: String): Long = MinecraftHssResourceLoader.version(location) } @@ -68,7 +76,11 @@ object MinecraftHssResourceLoader : HssResourceLoader { } override fun version(location: String): Long { - return HollowUiResourceAccess.version(resourceLocation(location)) + var version = HollowUiResourceAccess.version(resourceLocation(location)) + imports[location]?.forEach { imported -> + version = version * 31L + HollowUiResourceAccess.version(resourceLocation(imported)) + } + return version } private fun resourceLocation(location: String): ResourceLocation = diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiGlyphFont.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiGlyphFont.kt index a62412044..7959612a7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiGlyphFont.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiGlyphFont.kt @@ -101,8 +101,12 @@ private const val StrikethroughAscenderShare = 0.32f /** Mirrors the shaders' floor on `pxRange`, the screen pixels one distance-field unit spans. */ internal const val MinGlyphPxRange = 2f -/** Mirrors the shaders' `1 + Softness`: the width, in screen pixels, of the coverage transition. */ -internal const val GlyphEdgeSoftness = 1.15f +/** + * The width, in screen pixels, of a glyph's coverage transition. Both text shaders take it from + * here: `rect_sdf.fsh` as `GlyphEdgeSoftness`, `msdf_text.fsh` as `1 + Softness`. It also lowers + * [MaxBoldDistanceBias]; above 1.25 a default bold no longer fits even an 8px distance range. + */ +internal const val GlyphEdgeSoftness = 1.2f /** * Ceiling on the faux-bold distance bias, and the reason there has to be one: pixels further from a diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextFont.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextFont.kt index 4852d7ded..f0b0321e9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextFont.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiTextFont.kt @@ -11,6 +11,8 @@ internal sealed interface UiTextFont { fun lineHeight(fontSize: Float): Float + fun underlineY(fontSize: Float): Float + fun width(text: String, fontSize: Float, style: UiInlineStyle): Float } @@ -45,12 +47,15 @@ internal object UiTextFonts { override fun lineHeight(fontSize: Float): Float = fontSize + override fun underlineY(fontSize: Float): Float = fontSize * EstimatedUnderlineShare + override fun width(text: String, fontSize: Float, style: UiInlineStyle): Float { val scale = fontSize / EstimateBaselineFontSize return text.length * (EstimatedGlyphWidth * scale + style.boldWeight * fontSize) } private const val EstimateBaselineFontSize = 10f + private const val EstimatedUnderlineShare = 0.9f } private data class MsdfTextFont( @@ -61,6 +66,9 @@ internal object UiTextFonts { override fun lineHeight(fontSize: Float): Float = metrics.lineHeight(fontSize) + override fun underlineY(fontSize: Float): Float = + (metrics.meta.metrics.ascender - metrics.meta.metrics.underlineY) * fontSize + override fun width(text: String, fontSize: Float, style: UiInlineStyle): Float { val boldOffset = style.boldWeight * fontSize return metrics.width(text, fontSize) + text.length * boldOffset @@ -76,6 +84,9 @@ internal object UiTextFonts { override fun lineHeight(fontSize: Float): Float = face.lineHeight(fontSize) + override fun underlineY(fontSize: Float): Float = + (UiVanillaFont.ascenderEm - UiVanillaFont.underlineYEm) * fontSize + override fun width(text: String, fontSize: Float, style: UiInlineStyle): Float = face.width(text, fontSize) + text.length * style.boldWeight * fontSize } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiVanillaFont.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiVanillaFont.kt index dd116f687..c96ea7f99 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiVanillaFont.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/text/UiVanillaFont.kt @@ -236,7 +236,7 @@ object UiVanillaFont { val glyphs = HashMap() val coloredSheets = HashSet() - /** Pixel size of each sheet, so the renderer can sample texel centres rather than cell seams. */ + /** Pixel size of each sheet, so the renderer can sample texel centers rather than cell seams. */ val sheetSizes = HashMap() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/ExpressionEditing.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/ExpressionEditing.kt new file mode 100644 index 000000000..0be6a7df2 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/ExpressionEditing.kt @@ -0,0 +1,103 @@ +package ru.hollowhorizon.hollowengine.client.ui.widgets + +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.common.utils.expressions.Diagnostics +import ru.hollowhorizon.hollowengine.common.utils.expressions.ExprType +import ru.hollowhorizon.hollowengine.common.utils.expressions.Expression +import ru.hollowhorizon.hollowengine.common.utils.expressions.Lexer +import ru.hollowhorizon.hollowengine.common.utils.expressions.Severity +import ru.hollowhorizon.hollowengine.common.utils.expressions.TokenType + +/** + * What a text field needs to edit an expression of one dialect: colors for its tokens, the names it + * can complete, and the dialect's own diagnostics. Every editor that takes an expression (the + * animator, the effect editor) builds one from its [Expression] and hands the three to its field. + */ +class ExpressionEditing(private val language: Expression) { + val completions: UiCompletionContributor = UiCompletionContributor { context -> + val before = context.text.take(context.caret.coerceIn(0, context.text.length)) + val member = MemberAccess.find(before) + val prefix = member?.groupValues?.get(2) ?: before.takeLastWhile { it.isLetterOrDigit() || it == '_' } + val candidates = member?.let { membersOf(it.groupValues[1]) } ?: topLevel() + + candidates + .filter { prefix.isEmpty() || it.name.startsWith(prefix, ignoreCase = true) } + .map { UiTextCompletion(label = it.name, detail = it.parameters, tail = it.type) } + } + + val highlighter: UiSyntaxHighlighter = UiSyntaxHighlighter { text -> + if (text.isBlank()) return@UiSyntaxHighlighter emptyList() + + Lexer(text, Diagnostics()).tokenize().mapNotNull { token -> + val color = when (token.type) { + TokenType.NUMBER -> ExpressionTokenColors.Number + TokenType.STRING -> ExpressionTokenColors.String + TokenType.BOOLEAN -> ExpressionTokenColors.Keyword + TokenType.IDENTIFIER -> ExpressionTokenColors.Name + TokenType.EOF -> return@mapNotNull null + else -> ExpressionTokenColors.Operator + } + UiTextHighlight(token.span.start, token.span.end, UiInlineStyle().withColor(color)) + } + } + + fun diagnostics(source: String): List { + if (source.isBlank()) return emptyList() + + return runCatching { language.bake(source).diagnostics } + .getOrDefault(emptyList()) + .map { diagnostic -> + UiTextDiagnostic( + start = diagnostic.span.start.coerceIn(0, source.length), + end = diagnostic.span.end.coerceIn(0, source.length), + message = diagnostic.message, + severity = when (diagnostic.severity) { + Severity.ERROR -> UiTextDiagnosticSeverity.ERROR + Severity.WARNING -> UiTextDiagnosticSeverity.WARNING + }, + ) + } + } + + /** What `root.` offers: the members of the root, or of the receiver of that name. */ + private fun membersOf(root: String): List { + val declarations = language.declarations + val type = declarations.root(root)?.type + ?: declarations.receivers.firstOrNull { it.name == root }?.type + ?: return emptyList() + return type.described() + } + + /** The roots, and the members of the receivers, which are readable without a root. */ + private fun topLevel(): List { + val declarations = language.declarations + val roots = declarations.roots.map { (name, field) -> Candidate(name, "", field.type.name) } + val members = declarations.receivers.flatMap { it.type.described() } + return (roots + members).distinctBy { it.name }.sortedBy { it.name } + } + + private fun ExprType.described(): List { + val fields = members.allFields.flatMap { field -> field.names.map { Candidate(it, "", field.type.name) } } + val methods = members.allMethods.flatMap { method -> + val parameters = method.parameters.joinToString(prefix = "(", postfix = ")") { it.name } + method.names.map { Candidate(it, parameters, method.type.name) } + } + return (fields + methods).distinctBy { it.name }.sortedBy { it.name } + } + + /** One name to offer: the parameters of a function right after it, and its type at the right edge. */ + private class Candidate(val name: String, val parameters: String, val type: String) + + private companion object { + val MemberAccess = Regex("""([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*([A-Za-z0-9_]*)$""") + } +} + +/** The colors of expression tokens, the same in every editor that shows an expression. */ +object ExpressionTokenColors { + val Number = UiColor(0.72f, 0.62f, 0.92f) + val String = UiColor(0.62f, 0.82f, 0.55f) + val Keyword = UiColor(0.85f, 0.55f, 0.42f) + val Name = UiColor(0.42f, 0.72f, 0.92f) + val Operator = UiColor(0.62f, 0.66f, 0.72f) +} 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 71ea194ed..0da5f61ff 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 @@ -12,7 +12,7 @@ import ru.hollowhorizon.hollowengine.client.ui.text.Shadow * Compose-observable holder for an editable text field. Owns the whole edit model - text plus a * list of carets/selections, and exposes edit operations directly. [caretRanges] is the single * source of truth; the primary caret is always the last range. Also carries the field's view - * config (font, wrap, colours) so the widget itself stays a thin `EditableTextField(state)`. + * config (font, wrap, colors) so the widget itself stays a thin `EditableTextField(state)`. */ class TextFieldState( initialText: String = "", 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 031cf23a5..66aa0f18b 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 @@ -23,8 +23,8 @@ fun UiCodeEditor( completionRevision: Long = inlayRevision, readOnly: Boolean = false, fontSize: Float = DefaultUiFontSize, - fontFamily: String? = CodeEditorFontFamily, - textShadow: Shadow? = CodeEditorTextShadow, + fontFamily: String? = UiCodeFontFamily, + textShadow: Shadow? = null, state: TextFieldState? = null, id: String? = null, tags: Iterable = emptyList(), @@ -70,9 +70,9 @@ fun UiCodeEditor( ) } -private const val CodeEditorFontFamily = "hollowengine:fonts/monocraft" +/** The font of code and logs; the rest of the interface keeps the game's own. */ +const val UiCodeFontFamily = "ttf:hollowengine:fonts/jetbrains_mono_nl.ttf" -private val CodeEditorTextShadow = Shadow(offsetX = 0f, offsetY = 1f, blur = 2f, color = UiColor(1f, 1f, 1f, 0.4f)) private val CodeEditorCaretColor = UiColor(0.941f, 0.965f, 1f, 1f) private val CodeEditorSelectionColor = UiColor(0.373f, 0.549f, 0.804f, 0.46f) private val CodeEditorLineNumberColor = UiColor(0.333f, 0.376f, 0.439f, 1f) 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 bdfe8d4c3..5307a66f3 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 @@ -50,6 +50,8 @@ internal class EditableFieldLayout( internal val lineInputs: Array = arrayOfNulls(lines.size), /** Multi-line editors keep one line of overscroll past the last row; single-line fields don't. */ private val verticalOverscroll: Boolean = true, + /** Height of one unwrapped line in the field's font; the caret spans it. */ + val lineHeight: Float = fontSize, ) { private val trailingMargin = if (verticalOverscroll) fontSize else 0f val height: Float get() = (offsets.lastOrNull() ?: 0f) + trailingMargin @@ -362,6 +364,7 @@ internal fun computeEditableFieldLayout( lines, offsets, layouts, fontSize, fontFamily, contentWidth, naturalWidth, hintsById, inlayRevision, inlayHints.mapTo(HashSet(inlayHints.size)) { it.offset }, layoutWidth, lineInputs, verticalOverscroll = multiline, + lineHeight = uniformHeight, ) } @@ -970,7 +973,7 @@ private fun EditableFieldRow( val caret = layout.caretAt(range.position, range.inlayAffinity) key("caret", caretIndex) { Box( - modifier = Modifier.position(caret.x.px, caret.y.px).size(TextFieldCaretWidth.px, fontSize.px) + modifier = Modifier.position(caret.x.px, caret.y.px).size(TextFieldCaretWidth.px, layout.lineHeight.px) .background(state.caretColor).layer(1).animation( UiCaretBlinkKeyframes, UiCaretBlinkPeriodMillis, iterationCount = Float.POSITIVE_INFINITY ), @@ -1287,6 +1290,7 @@ internal fun handleEditableFieldKey( GLFW.GLFW_KEY_LEFT, GLFW.GLFW_KEY_RIGHT -> completion.close() } } + 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 (completion != null && (input.command && input.key == GLFW.GLFW_KEY_SPACE || input.alt && (input.key == GLFW.GLFW_KEY_ENTER || input.key == GLFW.GLFW_KEY_KP_ENTER))) { completion.open() diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCodeInsight.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCodeInsight.kt index 7a749464e..33e5430da 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCodeInsight.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldCodeInsight.kt @@ -142,14 +142,9 @@ private fun EditableFieldSignatureHelpPopup( visible: Boolean, ) { val anchor = layout.caretAt(help.anchor) - val viewport = scrollState.viewport + val at = scrollState.toScreen(contentOffsetX + anchor.x - scrollState.offsetX, anchor.y - scrollState.offsetY) Popup( - anchorBounds = UiRect( - viewport.x + contentOffsetX + anchor.x - scrollState.offsetX, - viewport.y + anchor.y - scrollState.offsetY, - 0f, - layout.fontSize, - ), + anchorBounds = UiRect(at.x, at.y, 0f, layout.fontSize * scrollState.screenScale), alignment = UiPopupAlignment( anchorVertical = UiAlign.START, popupVertical = UiAlign.END, @@ -158,7 +153,7 @@ private fun EditableFieldSignatureHelpPopup( id = "editable-text-field-signature-help", visible = visible, tags = listOf("ide-code-insight-popup", "ide-signature-help"), - modifier = codeInsightPopupModifier(layout, viewport), + modifier = codeInsightPopupModifier(layout, scrollState.viewport), dismissOnOutside = false, ) { Column(modifier = Modifier.size(UiLength.Fit, UiLength.Fit).gap(3.px)) { @@ -191,21 +186,22 @@ private fun EditableFieldHoverPopup( contentOffsetX: Float, visible: Boolean, ) { - val viewport = scrollState.viewport val start = layout.caretAt(info.start) val end = layout.caretAt(info.end) + val at = scrollState.toScreen(contentOffsetX + start.x - scrollState.offsetX, start.y - scrollState.offsetY) + val scale = scrollState.screenScale Popup( anchorBounds = UiRect( - viewport.x + contentOffsetX + start.x - scrollState.offsetX, - viewport.y + start.y - scrollState.offsetY, - (end.x - start.x).coerceAtLeast(layout.fontSize), - layout.fontSize, + at.x, + at.y, + (end.x - start.x).coerceAtLeast(layout.fontSize) * scale, + layout.fontSize * scale, ), alignment = UiPopupAlignment.BelowStart.copy(offsetY = CodeInsightPopupGap), id = "editable-text-field-hover-info", visible = visible, tags = listOf("ide-code-insight-popup", "ide-hover-info"), - modifier = codeInsightPopupModifier(layout, viewport), + modifier = codeInsightPopupModifier(layout, scrollState.viewport), dismissOnOutside = false, ) { Column(modifier = Modifier.size(UiLength.Fit, UiLength.Fit).gap(5.px)) { @@ -241,10 +237,7 @@ private fun CodeInsightText( CodeInsightSpans(text, segments) } else { CodeInsightSpans(text, segments.subList(0, firstActive)) - Text( - tags = listOf("ide-active-parameter"), - modifier = Modifier.background(CodeInsightActiveParameter), - ) { + Text(tags = listOf("ide-active-parameter")) { CodeInsightSpans(text, segments.subList(firstActive, lastActive + 1)) } CodeInsightSpans(text, segments.subList(lastActive + 1, segments.size)) @@ -312,9 +305,6 @@ private fun codeInsightPopupModifier(layout: EditableFieldLayout, viewport: UiRe val maxWidth = (viewport.width - CodeInsightViewportMargin * 2f).coerceAtLeast(1f) return Modifier.size(UiLength.Fit, UiLength.Fit) .maxSize(width = maxWidth.px) - .padding(8.px) - .background(CodeInsightBackground) - .border(1.px, CodeInsightBorder, 4f) .clip() .scrollable(vertical = true, horizontal = false, hasHorizontalScrollbar = false) .fontSize(layout.fontSize) @@ -325,7 +315,4 @@ private const val EditorHoverDelayMillis = 1_000L private const val CodeInsightPopupGap = 5f private const val CodeInsightViewportMargin = 8f private const val CodeInsightMaxVisibleSignatures = 6 -private val CodeInsightBackground = parseColor("#24272E") -private val CodeInsightBorder = parseColor("#3B404A") -private val CodeInsightActiveParameter = parseColor("#334A63") private val CodeInsightDocumentation = parseColor("#A9B7C6") 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 85f04afae..902721e88 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 @@ -11,6 +11,7 @@ import ru.hollowhorizon.hollowengine.client.ui.style.UiGradientStop import ru.hollowhorizon.hollowengine.client.ui.style.UiTransition import ru.hollowhorizon.hollowengine.client.ui.style.parseColor import ru.hollowhorizon.hollowengine.client.ui.text.UiTextLayouter +import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.generated.Assets import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType import kotlin.math.floor @@ -24,6 +25,7 @@ internal data class EditableFieldCompletionGeometry( val listHeight: Float, val rowHeight: Float, val visibleRows: Int, + val footerHeight: Float, ) internal fun editableFieldCompletionGeometry( @@ -36,6 +38,7 @@ internal fun editableFieldCompletionGeometry( viewportHeight: Float, caretOffsetX: Float = 0f, caretOffsetY: Float = 0f, + scale: Float = 1f, ): EditableFieldCompletionGeometry? { if (items.isEmpty() || viewportWidth <= 0f || viewportHeight <= 0f) return null val fontSize = layout.fontSize @@ -51,9 +54,9 @@ internal fun editableFieldCompletionGeometry( val rowHeight = (textHeight + CompletionPopupRowVerticalChrome).coerceAtLeast(14f) val footerHeight = (textHeight + CompletionPopupFooterVerticalChrome).coerceAtLeast(rowHeight) val caret = layout.caretAt(anchor) - val caretX = caretOffsetX + caret.x - scrollX - val caretY = caretOffsetY + caret.y - scrollY - val belowY = caretY + textHeight + CompletionPopupAnchorGap + val caretX = caretOffsetX + (caret.x - scrollX) * scale + val caretY = caretOffsetY + (caret.y - scrollY) * scale + val belowY = caretY + textHeight * scale + CompletionPopupAnchorGap val belowSpace = viewportHeight - belowY - CompletionPopupViewportMargin val aboveSpace = caretY - CompletionPopupAnchorGap - CompletionPopupViewportMargin val minimumHeight = popupMinimumHeight(rowHeight, footerHeight) @@ -85,11 +88,12 @@ internal fun editableFieldCompletionGeometry( width += UiTextLayouter.measureTextWidth(item.detail, fontSize, fontFamily) + CompletionPopupTextGap } if (item.tail.isNotBlank()) { - width += UiTextLayouter.measureTextWidth(item.tail, fontSize, fontFamily) + CompletionPopupTextGap + width += UiTextLayouter.measureTextWidth(item.tail, fontSize * CompletionTailScale, fontFamily) + + CompletionPopupTextGap } width } ?: 0f - val availableWidth = (viewportWidth - CompletionPopupViewportMargin * 2f).coerceAtLeast(1f) + val availableWidth = (viewportWidth - CompletionPopupViewportMargin * 2f).coerceIn(1f, CompletionPopupMaxWidth) val minWidth = minOf(CompletionPopupMinWidth, availableWidth) val width = (labelWidth + CompletionPopupRowChrome).coerceIn(minWidth, availableWidth) val x = caretX.coerceIn( @@ -101,7 +105,28 @@ internal fun editableFieldCompletionGeometry( useBelow -> belowY else -> caretY - height - CompletionPopupAnchorGap } - return EditableFieldCompletionGeometry(x, y, width, height, listHeight, rowHeight, visibleRows) + return EditableFieldCompletionGeometry(x, y, width, height, listHeight, rowHeight, visibleRows, footerHeight) +} + +/** The keys that drive the list, one row under it, centered in the height the geometry gave it. */ +@Composable +private fun CompletionPopupHint(height: Float) { + Row(tags = listOf("ide-completion-hint"), modifier = Modifier.size(100.percent, height.px)) { + Image(Assets.Hollowengine.Textures.Gui.Icons.COMPLETIONS.toString(), tags = listOf("ide-completion-hint-icon")) + Text(CompletionHintLang.NAVIGATE.lang, modifier = Modifier.textWrap(false)) + Text("Enter", tags = listOf("ide-completion-hint-key"), modifier = Modifier.textWrap(false)) + Text(CompletionHintLang.OR.lang, modifier = Modifier.textWrap(false)) + Text("Tab", tags = listOf("ide-completion-hint-key"), modifier = Modifier.textWrap(false)) + Text(CompletionHintLang.INSERT.lang, modifier = Modifier.textWrap(false)) + } +} + +private object CompletionHintLang { + private const val ROOT = "hollowengine.gui.ide.editor.completion." + + const val NAVIGATE = ROOT + "navigate" + const val OR = ROOT + "or" + const val INSERT = ROOT + "insert" } private fun popupMinimumHeight(rowHeight: Float, footerHeight: Float): Float = @@ -122,6 +147,8 @@ internal fun EditableFieldCompletionPopup( val items = completion.items val field = scrollState.viewport val surface = LocalUiViewport.current.takeIf { it.width > 0f && it.height > 0f } ?: field + val scale = scrollState.screenScale + val origin = scrollState.toScreen(0f, 0f) val geometry = editableFieldCompletionGeometry( layout = layout, anchor = completion.anchor, @@ -130,8 +157,9 @@ internal fun EditableFieldCompletionPopup( scrollY = scrollState.offsetY, viewportWidth = surface.width, viewportHeight = surface.height, - caretOffsetX = contentOffsetX + field.x - surface.x, - caretOffsetY = field.y - surface.y, + caretOffsetX = contentOffsetX * scale + origin.x - surface.x, + caretOffsetY = origin.y - surface.y, + scale = scale, ) ?: return val listScroll = rememberScrollState() @@ -192,23 +220,8 @@ internal fun EditableFieldCompletionPopup( Box(modifier = Modifier.size(100.percent, (remaining * geometry.rowHeight).px)) } } - Box( - modifier = Modifier.size(UiLength.Fill, 1.px) - .background(parseColor("#31343D")), - ) - Row(tags = listOf("ide-completion-hint")) { - Text(modifier = Modifier.textWrap(false)) { - Image( - Assets.Hollowengine.Textures.Gui.Icons.COMPLETIONS.toString(), - modifier = Modifier.size(16.px, 16.px), - ) - Span("to navigate. ", modifier = Modifier.foreground(parseColor("#5F6677"))) - Span(" Enter ", modifier = Modifier.foreground(parseColor("#C4CBDA"))) - Span("or", modifier = Modifier.foreground(parseColor("#5F6677"))) - Span(" Tab ", modifier = Modifier.foreground(parseColor("#C4CBDA"))) - Span("to insert.", modifier = Modifier.foreground(parseColor("#5F6677"))) - } - } + Box(tags = listOf("ide-completion-divider"), modifier = Modifier.size(UiLength.Fill, 1.px)) + CompletionPopupHint(geometry.footerHeight) } } @@ -359,7 +372,8 @@ private fun CompletionTail(tail: String, horizontalAlign: UiAlign, onWidthChange Text( tail, tags = listOf("ide-completion-tail"), - modifier = Modifier.align(horizontalAlign, UiAlign.CENTER).onPlaced { onWidthChanged(it.width) }, + modifier = Modifier.align(horizontalAlign, UiAlign.CENTER).fontScale(CompletionTailScale) + .onPlaced { onWidthChanged(it.width) }, ) } @@ -420,10 +434,11 @@ private val CompletionMatchColor = parseColor("#6CB6FF") private const val CompletionFadeWidth = 16f private const val CompletionFadeDurationMillis = 300L private const val CompletionFadeOvershoot = 2f -private val CompletionPopupBg = parseColor("#24272E") -private val CompletionSelectedBg = UiColor(67f / 255f, 106f / 255f, 154f / 255f, 0.62f).over(CompletionPopupBg) -private val CompletionSelectedHoverBg = UiColor(84f / 255f, 124f / 255f, 176f / 255f, 0.72f).over(CompletionPopupBg) -private val CompletionHoverBg = UiColor(67f / 255f, 92f / 255f, 132f / 255f, 0.34f).over(CompletionPopupBg) +/** The row colors of .ide-completion-row, flattened onto .ide-completion-popup, for the overflow fades. */ +private val CompletionPopupBg = parseColor("#26282E") +private val CompletionSelectedBg = UiColor(110f / 255f, 155f / 255f, 220f / 255f, 0.24f).over(CompletionPopupBg) +private val CompletionSelectedHoverBg = UiColor(110f / 255f, 155f / 255f, 220f / 255f, 0.30f).over(CompletionPopupBg) +private val CompletionHoverBg = UiColor(1f, 1f, 1f, 0.05f).over(CompletionPopupBg) private fun completionFadeStops(color: UiColor) = listOf( UiGradientStop(0f, color.copy(alpha = 0f)), @@ -504,3 +519,7 @@ private const val CompletionPopupWindowSize = 48 private const val CompletionPopupOverscanRows = 12 private const val CompletionPopupTextGap = 8f private const val CompletionPopupRowChrome = 52f +private const val CompletionPopupMaxWidth = 560f + +/** The package tail reads a step smaller than the label; measured and drawn at the same scale. */ +private const val CompletionTailScale = 0.85f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldDiagnostics.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldDiagnostics.kt index 319189f26..cbc0bb978 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldDiagnostics.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldDiagnostics.kt @@ -10,25 +10,41 @@ import ru.hollowhorizon.hollowengine.client.ui.shape.UiShapeSize import ru.hollowhorizon.hollowengine.client.ui.shape.path import ru.hollowhorizon.hollowengine.client.ui.style.UiPaint import ru.hollowhorizon.hollowengine.client.ui.text.UiTextLayout +import ru.hollowhorizon.hollowengine.client.ui.text.UiTextFonts import ru.hollowhorizon.hollowengine.client.ui.text.UiTextLayouter import ru.hollowhorizon.hollowengine.client.ui.text.caretPosition import kotlin.math.ceil import kotlin.math.min +import kotlin.math.roundToInt -internal data class ZigZagUnderlineShape(val step: Float) : Shape { +/** A smooth wave, one crest or trough per [halfWave], through the middle of its box. */ +internal data class WavyUnderlineShape(val halfWave: Float) : Shape { override fun createPath(size: UiShapeSize): UiPath = path { - moveTo(0f, size.height / 2f) + val middle = size.height / 2f + moveTo(0f, middle) var x = 0f - var high = true + var crest = true while (x < size.width) { - x = min(x + step, size.width) - lineTo(x, if (high) 0f else size.height) - high = !high + val next = min(x + halfWave, size.width) + quadraticBezierTo((x + next) / 2f, if (crest) 0f else size.height, next, middle) + crest = !crest + x = next } } } -internal val EditableFieldZigZagShape = ZigZagUnderlineShape(EditableFieldDiagnosticStep) +/** + * The squiggle's proportions for [fontSize], so it scales with the text. Half-waves are rounded to + * half a pixel: the stroke mesh is cached per shape, and every zoom step must not mint a new one. + */ +internal class DiagnosticUnderline(fontSize: Float, fontFamily: String?) { + val halfWave: Float = (fontSize * 0.28f * 2f).roundToInt().coerceAtLeast(4) / 2f + val amplitude: Float = (fontSize * 0.1f).coerceIn(1f, 3f) + val thickness: Float = (fontSize / 12f).coerceIn(0.9f, 2f) + val shape = WavyUnderlineShape(halfWave) + + val middle: Float = UiTextFonts.resolve(fontFamily).underlineY(fontSize) +} internal fun UiTextDiagnosticSeverity.diagnosticUnderlineColor(): UiColor = when (this) { UiTextDiagnosticSeverity.ERROR -> UiColor(1f, 0.33f, 0.33f, 0.9f) @@ -71,6 +87,7 @@ internal fun EditableFieldRowDiagnostics( fontFamily: String?, contentWidth: Float, ) { + val underline = DiagnosticUnderline(fontSize, fontFamily) rowDiagnostics.forEachIndexed { diagnosticIndex, diagnostic -> val localStart = diagnostic.start.coerceIn(0, line.text.length) val localEnd = diagnostic.end.coerceIn(localStart, line.text.length) @@ -87,18 +104,19 @@ internal fun EditableFieldRowDiagnostics( } val color = diagnostic.severity.diagnosticUnderlineColor() rects.forEachIndexed { rectIndex, rect -> - val width = (ceil(rect.width / EditableFieldDiagnosticStep) * EditableFieldDiagnosticStep) - .coerceAtLeast(EditableFieldDiagnosticStep * 2f) + val width = (ceil(rect.width / underline.halfWave) * underline.halfWave) + .coerceAtLeast(underline.halfWave * 2f) + val middle = top + rect.y + underline.middle key("diag", diagnosticIndex, rectIndex) { Box( modifier = Modifier - .position(rect.x.px, (top + rect.y + rect.height - EditableFieldDiagnosticAmplitude).px) - .size(width.px, (EditableFieldDiagnosticAmplitude * 2f).px) + .position(rect.x.px, (middle - underline.amplitude).px) + .size(width.px, (underline.amplitude * 2f).px) .shape( - EditableFieldZigZagShape, + underline.shape, fill = UiPaint.None, stroke = UiPaint.Color(color), - strokeWidth = EditableFieldDiagnosticThickness.px, + strokeWidth = underline.thickness.px, ), ) } @@ -112,6 +130,7 @@ internal data class EditableFieldDiagnosticTooltip( val x: Float, val y: Float, val width: Float, + val fontSize: Float, ) internal fun editableFieldDiagnosticTooltipAt( @@ -142,7 +161,7 @@ internal fun editableFieldDiagnosticTooltipAt( availableWidth = maxWidth - DiagnosticTooltipHorizontalPadding, knownWidth = null, wrap = true, - fontSize = DiagnosticTooltipFontSize, + fontSize = layout.fontSize, fontFamily = layout.fontFamily, ) val width = (measured.width + DiagnosticTooltipHorizontalPadding).coerceIn(140f, maxWidth) @@ -156,6 +175,7 @@ internal fun editableFieldDiagnosticTooltipAt( x = x, y = y, width = width, + fontSize = layout.fontSize, ) } @@ -183,15 +203,11 @@ internal fun EditableFieldDiagnosticTooltipOverlay(tooltip: EditableFieldDiagnos modifier = Modifier.size(UiLength.Fill, UiLength.Auto) .whitespace(UiWhitespace.COLLAPSE) .textWrap(true) - .fontSize(DiagnosticTooltipFontSize), + .fontSize(tooltip.fontSize), ) } } -internal const val EditableFieldDiagnosticStep = 3f -internal const val EditableFieldDiagnosticAmplitude = 2f -internal const val EditableFieldDiagnosticThickness = 1.25f -private const val DiagnosticTooltipFontSize = 11f private const val DiagnosticTooltipHorizontalPadding = 18f private const val DiagnosticTooltipVerticalPadding = 10f private const val DiagnosticTooltipMinHeight = 24f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldQuickFix.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldQuickFix.kt index c9c45c05a..8bc2aef18 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldQuickFix.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/widgets/UiEditableTextFieldQuickFix.kt @@ -162,8 +162,9 @@ internal fun EditableFieldQuickFixPopup( val y = (caret.y - scrollState.offsetY + fontSize + QuickFixAnchorGap) .coerceAtMost((viewport.height - QuickFixViewportMargin).coerceAtLeast(0f)) + val at = scrollState.toScreen(x, y) Popup( - anchorBounds = UiRect(viewport.x + x, viewport.y + y, 0f, 0f), + anchorBounds = UiRect(at.x, at.y, 0f, 0f), alignment = UiPopupAlignment(anchorVertical = UiAlign.START), id = "editable-text-field-quick-fix", tags = listOf("editable-text-field-quick-fix-popup", "ide-quick-fix-popup"), 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 d6e18a7a2..ddba180b6 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.* import com.mojang.blaze3d.systems.RenderSystem import com.mojang.blaze3d.vertex.PoseStack import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive import net.minecraft.client.Minecraft import net.minecraft.client.renderer.LightTexture import net.minecraft.client.renderer.texture.OverlayTexture @@ -53,7 +54,8 @@ class ModelViewerState(model: String) { var yaw by mutableStateOf(0f) var pitch by mutableStateOf(0f) - var zoom by mutableStateOf(1f) + private val zoomSpring = SpringZoom(1f, min = 0.1f, max = 10f, perNotch = ModelZoomPerNotch) + val zoom: Float get() = zoomSpring.value var offsetX by mutableStateOf(0f) var offsetY by mutableStateOf(0f) @@ -78,6 +80,11 @@ class ModelViewerState(model: String) { val modelFlow: StateFlow get() = attachment.flow + /** One wheel notch; the scale springs to where it sends it. */ + fun zoomBy(scrollY: Float) = zoomSpring.scroll(scrollY) + + internal fun advanceZoom(frameNanos: Long) = zoomSpring.advance(frameNanos) + /** The model's runtime node hierarchy (roots), for inspection UIs. */ val nodes: List get() { @@ -324,6 +331,9 @@ fun Model( modifier: Modifier? = null, onDrag: ((UiEvent) -> Boolean)? = null, ) { + LaunchedEffect(state) { + while (isActive) withFrameNanos(state::advanceZoom) + } Box( id = id, tags = tags, @@ -340,8 +350,7 @@ fun Model( } event.consume() }.onScroll { event -> - val factor = if (event.scrollY > 0f) 0.9f else 1.1f - state.zoom = (state.zoom * factor).coerceIn(0.1f, 10f) + state.zoomBy(event.scrollY) event.consume() }.drawBehind(key = state) { if (state.showGrid) drawGrid(state) 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 d1de98f33..83a5cbc3b 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 @@ -169,7 +169,7 @@ sealed interface UiInlayContent { data class Icon(val source: String) : UiInlayContent - /** A solid colour chip in `0xAARRGGBB`, drawn over a checkerboard so alpha reads. */ + /** A solid color chip in `0xAARRGGBB`, drawn over a checkerboard so alpha reads. */ data class Swatch(val argb: Int) : UiInlayContent } 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 c5298f1e9..c54f9f38f 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 @@ -36,6 +36,26 @@ data class UiTreeItem( val selected: Boolean = false, ) +/** Asks the tree to scroll [itemId] into view; a new [revision] asks again for the same item. */ +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 + val later = BooleanArray(maxDepth + 2) + val result = arrayOfNulls(items.size) + for (index in items.indices.reversed()) { + val depth = items[index].depth + result[index] = BooleanArray(depth) { level -> later[level + 1] } + later[depth] = true + for (deeper in depth + 1 until later.size) later[deeper] = false + } + return result.map { it ?: BooleanArray(0) } +} + @Composable fun UiTreeView( items: List>, @@ -52,7 +72,19 @@ fun UiTreeView( filterPlaceholder: String = "Filter", onFilterOpened: ((String) -> Unit)? = null, scrollState: UiScrollHandle = rememberScrollState(), + onBackgroundClick: (() -> Unit)? = null, + onBackgroundContextMenu: ((UiEvent) -> Unit)? = null, + reveal: UiTreeReveal? = null, + onRevealed: (() -> Unit)? = null, ) { + val latestItems by rememberUpdatedState(items) + if (reveal != null) { + LaunchedEffect(reveal) { + scrollToRow(scrollState, reveal.itemId) { latestItems } + onRevealed?.invoke() + } + } + val continuations = remember(items) { treeGuideContinuations(items) } Column( tags = listOf("tree-view") + tags, modifier = modifier.focus().onKeyInput(FilterShortcutPriority) { input -> @@ -75,12 +107,21 @@ fun UiTreeView( } Column( tags = listOf("tree-view-scroll"), - modifier = Modifier.size(100.percent, 0.px).grow(1f).scrollable(state = scrollState), + modifier = Modifier.size(100.percent, 0.px).grow(1f).scrollable(state = scrollState).then( + if (onBackgroundClick == null && onBackgroundContextMenu == null) Modifier else Modifier.input(clickable = true).onClick { event -> + if (event.button == GLFW.GLFW_MOUSE_BUTTON_RIGHT && onBackgroundContextMenu != null) { + onBackgroundContextMenu(event) + } else { + onBackgroundClick?.invoke() + } + event.consume() + }, + ), ) { - items.forEach { item -> + items.forEachIndexed { index, item -> key(item.id) { UiTreeRow( - item, onToggle, onSelect, onIconClick, fillRowWidth, onDrop, canDrop, + item, continuations[index], onToggle, onSelect, onIconClick, fillRowWidth, onDrop, canDrop, draggable = dragItem != null, ) { dragItem?.invoke(item) @@ -91,6 +132,20 @@ fun UiTreeView( } } +private suspend fun scrollToRow(scroll: UiScrollHandle, itemId: String, items: () -> List>) { + repeat(RevealLayoutFrames) { + withFrameNanos { } + val viewport = scroll.viewport.height + val index = items().indexOfFirst { it.id == itemId } + if (index < 0 || viewport <= 0f) return@repeat + val top = index * TreeRowHeight + val offset = scroll.offsetY + if (top >= offset && top + TreeRowHeight <= offset + viewport) return + scroll.animateScrollBy(deltaY = top - (viewport - TreeRowHeight) / 2f - offset) + return + } +} + @Composable private fun UiTreeFilter( state: UiTreeFilterState, @@ -125,6 +180,7 @@ private fun UiTreeFilter( @Composable private fun UiTreeRow( item: UiTreeItem, + continues: BooleanArray, onToggle: (UiTreeItem) -> Unit, onSelect: (UiTreeItem, UiEvent) -> Unit, onIconClick: ((UiTreeItem) -> Unit)?, @@ -152,7 +208,7 @@ private fun UiTreeRow( if (dragAndDrop?.hoveredTargetId == id) { if (dragAndDrop.canDrop) "drop-target" else "drop-rejected" } else null), - modifier = Modifier.size(if (fillRowWidth) 100.percent else UiLength.Auto, 24.px) + modifier = Modifier.size(if (fillRowWidth) 100.percent else UiLength.Auto, TreeRowHeight.px) .alignItems(vertical = UiAlign.CENTER) .input(hoverable = true, clickable = true) .cursor(UiCursorShape.HAND) @@ -160,8 +216,16 @@ private fun UiTreeRow( .dragSource(dragSource, drag) .then(dropModifier) ) { - repeat(item.depth) { - Box(tags = listOf("tree-indent")) + repeat(item.depth) { level -> + val parentLevel = level == item.depth - 1 + val opensSubtree = item.hasChildren && item.expanded + val guide = when { + parentLevel && opensSubtree -> if (continues[level]) "branch" else "last" + parentLevel -> if (continues[level]) "line" else "end" + continues[level] -> "line" + else -> null + } + Box(tags = listOfNotNull("tree-indent", guide)) } Box( tags = if (!item.hasChildren) listOf("tree-expander-empty") else listOf("tree-expander"), @@ -193,3 +257,5 @@ private fun UiTreeRow( } private const val FilterShortcutPriority = 100 +private const val TreeRowHeight = 20f +private const val RevealLayoutFrames = 4 diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/utils/IconHelper.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/utils/IconHelper.kt index c4a355a2e..27d526b4a 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/utils/IconHelper.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/utils/IconHelper.kt @@ -1,6 +1,7 @@ package ru.hollowhorizon.hollowengine.client.utils import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.common.utils.rl import ru.hollowhorizon.hollowengine.generated.Assets object IconHelper { @@ -30,18 +31,40 @@ object IconHelper { } } - else -> { - when (path.substringAfterLast('.')) { - "bc" -> Icons.FILE_CODEBLOCKS - "zip", "rar", "jar" -> Icons.FILE_ZIP - "gltf", "glb", "fbx", "geo.json", "obj" -> Icons.FILE_MODEL - "kts", "kt" -> Icons.FILE_KTS - "png", "jpg", "jpeg", "gif" -> Icons.FILE_IMAGE - "ogg", "mp3", "wav" -> Icons.FILE_SOUND - "mp4", "avi", "mov" -> Icons.FILM - else -> Icons.FILE - } - } + else -> forFile(path) } } -} \ No newline at end of file + + /** + * The icon of a file, by what it is: the engine's own formats first, since several of them end in a + * suffix another format also uses (`.node.kts` is a `.kts`, `.geo.json` is a `.json`). + */ + fun forFile(path: String): ResourceLocation { + val name = path.substringAfterLast('/').lowercase() + val icon = FileIcons.firstOrNull { (suffixes, _) -> suffixes.any(name::endsWith) }?.second ?: "file" + return "hollowengine:textures/gui/icons/files/$icon.svg".rl + } + + private val FileIcons: List, String>> = listOf( + listOf(".node.kts") to "script_node", + listOf(".ui.kts") to "script_ui", + listOf(".startup.kts") to "script_startup", + listOf(".reload.kts") to "script_reload", + listOf(".mixin.kts") to "script_mixin", + listOf(".kts", ".kt") to "script", + listOf(".vfx") to "effect", + listOf(".material") to "shader", + listOf(".animator") to "animator", + listOf(".rig") to "rig", + listOf(".story") to "story", + listOf(".hss") to "style", + listOf(".bc") to "blocks", + listOf(".gltf", ".glb", ".fbx", ".geo.json", ".obj") to "model", + listOf(".json", ".json5", ".mcmeta") to "data", + listOf(".png", ".jpg", ".jpeg", ".gif") to "image", + listOf(".ogg", ".mp3", ".wav") to "sound", + listOf(".mp4", ".avi", ".mov") to "video", + listOf(".zip", ".rar", ".jar") to "archive", + listOf(".txt", ".md", ".yml", ".yaml", ".toml", ".properties", ".lang", ".cfg") to "text", + ) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxAssets.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxAssets.kt new file mode 100644 index 000000000..ead051c0c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxAssets.kt @@ -0,0 +1,50 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.packs.resources.ResourceManager +import net.minecraft.server.packs.resources.ResourceManagerReloadListener +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxShaders +import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import ru.hollowhorizon.hollowengine.common.vfx.VfxFormat + +/** + * The `.vfx` files of every loaded pack, by resource id. + */ +object VfxAssets : ResourceManagerReloadListener { + private val effects = HashMap() + + val ids: Set get() = effects.keys + + operator fun get(id: ResourceLocation): VfxEffect? = effects[id] + + operator fun get(id: String): VfxEffect? = ResourceLocation.tryParse(normalize(id))?.let(effects::get) + + override fun onResourceManagerReload(resourceManager: ResourceManager) { + effects.clear() + resourceManager.listResources(VfxFormat.RESOURCE_FOLDER) { it.path.endsWith(VfxFormat.EXTENSION) } + .forEach { (location, resource) -> + try { + effects[location] = resource.open().use { stream -> + VfxFormat.read(stream.readBytes().toString(Charsets.UTF_8)) + } + } catch (e: Exception) { + HollowEngine.LOGGER.warn("Could not read effect {}: {}", location, e.message) + } + } + VfxScenes.onAssetsReloaded() + VfxShaders.clear() + VfxModelSurfaces.clear() + } + + /** Accepts both `pack:vfx/fire.vfx` and the bare `pack:fire` an author is likely to type. */ + private fun normalize(id: String): String { + val trimmed = id.trim() + val resource = trimmed.rl + val path = resource.path + val withFolder = if (path.startsWith("${VfxFormat.RESOURCE_FOLDER}/")) path else "${VfxFormat.RESOURCE_FOLDER}/$path" + val withExtension = if (withFolder.endsWith(VfxFormat.EXTENSION)) withFolder else "$withFolder${VfxFormat.EXTENSION}" + return "${resource.namespace}:$withExtension" + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxBoneAttachmentRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxBoneAttachmentRuntime.kt new file mode 100644 index 000000000..75afe618f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxBoneAttachmentRuntime.kt @@ -0,0 +1,125 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import net.minecraft.client.Minecraft +import net.minecraft.world.level.Level +import net.minecraft.world.phys.Vec3 +import org.joml.Matrix4f +import org.joml.Vector3f +import ru.hollowhorizon.hollowengine.client.handlers.TickHandler +import ru.hollowhorizon.hollowengine.client.models.internal.rendering.RenderPipeline +import ru.hollowhorizon.hollowengine.client.models.internal.v2.Attachment +import ru.hollowhorizon.hollowengine.client.models.internal.v2.RigAttachmentFactories +import ru.hollowhorizon.hollowengine.client.models.internal.v2.RuntimeNode +import ru.hollowhorizon.hollowengine.client.utils.math.asMatrix4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxBoneAttachmentSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTransform + +/** + * An effect attached to bone. + */ +class VfxBoneAttachment( + private val spec: VfxBoneAttachmentSpec, + private val node: RuntimeNode, +) : Attachment(node) { + private var instance: VfxInstance? = null + private var lastGameTime = Float.NaN + private var lastFrame = Long.MIN_VALUE + + /** Bone space to camera-relative space, captured while the model was drawn. */ + private val placement = Matrix4f() + + override fun collectCommands(pipeline: RenderPipeline) { + if (!spec.autoPlay || spec.effect.isBlank()) return + + pipeline.addBatchedRenderable { + if (!node.isVisible) return@addBatchedRenderable + + val frame = TickHandler.renderFrame + if (frame == lastFrame) return@addBatchedRenderable + lastFrame = frame + + placement.set(stack.last().pose()).mul(globalMatrix.asMatrix4f()) + VfxBoneBindings.submit(this@VfxBoneAttachment) + } + } + + /** Advances the effect and says where it is; called once a frame by the world renderer. */ + fun update(cameraPosition: Vec3): VfxBoneBinding? { + val level = Minecraft.getInstance().level ?: return null + val playing = instance ?: create(level) ?: return null + + val now = TickHandler.gameTime + val dt = when { + lastGameTime.isNaN() -> 0f + else -> ((now - lastGameTime) / TICKS_PER_SECOND).coerceIn(0f, MAX_STEP_SECONDS) + } + lastGameTime = now + + val local = Matrix4f(placement) + applyOffset(local) + + val translation = local.getTranslation(Vector3f()) + playing.moveTo( + Vec3( + cameraPosition.x + translation.x, + cameraPosition.y + translation.y, + cameraPosition.z + translation.z, + ) + ) + + playing.setCamera(cameraPosition) + playing.partialTick = TickHandler.partialTick + playing.gameTime = now + playing.budget = VfxBudget.share(1) + playing.update(dt) + + return VfxBoneBinding(playing, local) + } + + /** The offset the author gave the attachment, on top of the bone. */ + private fun applyOffset(matrix: Matrix4f) { + val transform = VfxTransform(spec.offset, spec.rotation, Vec3f(spec.scale, spec.scale, spec.scale)) + val frame = VfxFrame().setCombined(VfxFrame().setIdentity(), transform) + matrix.mul(frame.toMatrix(MutableMat4f()).asMatrix4f()) + } + + private fun create(level: Level): VfxInstance? { + val effect = VfxAssets[spec.effect] ?: return null + return VfxInstance(effect, spec.effect, VfxWorldEnvironment(level)).also { instance = it } + } + + private companion object { + const val TICKS_PER_SECOND = 20f + const val MAX_STEP_SECONDS = 0.25f + } +} + +/** One bone effect, already advanced, with the matrix that places it in front of the camera. */ +class VfxBoneBinding(val instance: VfxInstance, val placement: Matrix4f) + +/** + * The bone effects drawn this frame. + */ +object VfxBoneBindings { + private val submitted = ArrayList() + private val bindings = ArrayList() + + fun submit(attachment: VfxBoneAttachment) { + submitted += attachment + } + + fun drain(cameraPosition: Vec3): List { + bindings.clear() + submitted.forEach { attachment -> attachment.update(cameraPosition)?.let(bindings::add) } + submitted.clear() + return bindings + } + + fun register() { + RigAttachmentFactories.register("hollowengine:rig/vfx") { spec, context -> + VfxBoneAttachment(spec as VfxBoneAttachmentSpec, context.node) + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEmitter.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEmitter.kt new file mode 100644 index 000000000..c5afc8920 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEmitter.kt @@ -0,0 +1,499 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.client.utils.math.rotateBy +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.math.QuatF +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmitterSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxParticleEvent +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxSimulationSpace +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvAnimationSpec +import kotlin.math.ceil +import kotlin.math.sqrt +import kotlin.random.Random + +/** + * One emitter of a playing effect. It only simulates; the renderers under its node draw. + */ +class VfxEmitter( + val spec: VfxEmitterSpec, + val node: VfxNodeRuntime, +) : VfxNodeBehavior { + val instance: VfxInstance get() = node.instance + private val expressions: VfxExpressions = node.expressions + private val seed: Int = node.seed + + val particles = VfxParticles(spec.emission.maxParticles.coerceIn(1, MAX_PARTICLES_PER_EMITTER)) + + val frame: VfxFrame get() = node.frame + + val simToRender: MutableMat4f = MutableMat4f().setIdentity() + + val context = VfxEvalContext() + + private var random = Random(seed) + + private val rate = scalar(VfxProperty.RATE, spec.emission.rate, 0f) + private val shape = VfxEmitterShape(spec.shape, node) + + private val lifetime = scalar(VfxProperty.LIFETIME, spec.spawn.lifetime, 1f) + private val speed = scalar(VfxProperty.SPEED, spec.spawn.speed, 0f) + private val offset = vector(VfxProperty.OFFSET, spec.spawn.offset, 0f) + private val inheritVelocity = scalar(VfxProperty.INHERIT_VELOCITY, spec.spawn.inheritVelocity, 0f) + + private val gravity = scalar(VfxProperty.GRAVITY, spec.motion.gravity, 0f, VfxRangeMode.PER_PARTICLE) + private val drag = scalar(VfxProperty.DRAG, spec.motion.drag, 0f, VfxRangeMode.PER_PARTICLE) + + private val modules: List = spec.modules + .filter { it.enabled } + .mapNotNull { module -> + VfxModuleRuntimes.create(module, VfxModuleContext(spec, expressions) { field -> + node.drive(VfxProperty.module(module.id, field)) + }) + } + + /** Set when this emitter spawns from the particles of the emitter it sits under. */ + private val subInput: VfxSubEmitterInput? = spec.subEmission?.let { sub -> + node.parent?.emitter?.let { source -> VfxSubEmitterInput(sub, source, node) } + } + + /** The sub-emitters directly under this emitter; its children exist only after it does. */ + private val subEmitters: List by lazy { + node.children.mapNotNull { child -> child.emitter?.subInput?.takeIf { it.source === this } } + } + + /** What the renderers under this emitter give its particles, read while it simulates. */ + private val looks = ArrayList() + + /** Called by a renderer under this emitter as it is built. */ + fun addLook(look: VfxParticleLook) { + looks += look + } + + /** The flipbook grid the renderer cuts the material region into; one cell without a flipbook. */ + val uvColumns: Int + val uvRows: Int + + init { + val flipbook = spec.modules.filterIsInstance().firstOrNull { it.enabled } + uvColumns = flipbook?.columns?.coerceAtLeast(1) ?: 1 + uvRows = flipbook?.rows?.coerceAtLeast(1) ?: 1 + } + + /** An emitter whose node is off neither emits nor draws, but keeps the particles it has. */ + val enabled: Boolean get() = node.enabled + + var age: Float = 0f + private set + + /** Seconds until the next loop starts, while the emitter is sleeping between loops. */ + private var sleepLeft: Float = 0f + private var spawnDebt: Float = 0f + private var born: Int = 0 + private var stopping: Boolean = false + + /** Whether this emitter will ever produce anything again. */ + val isFinished: Boolean + get() { + if (particles.count > 0) return false + val source = subInput?.source ?: return stopping || isExpired() + return stopping || source.isFinished + } + + override val isIdle: Boolean get() = particles.count == 0 + + private val spawnOrigin = MutableVec3f() + private var spawnRotation: QuatF = QuatF.IDENTITY + private val spawnScale = MutableVec3f(Vec3f.ONES) + + private val scratchPosition = MutableVec3f() + private val scratchDirection = MutableVec3f() + private val scratchVector = MutableVec3f() + + /** Extra velocity the modules asked for, for this particle and this step only. */ + var stepVelocityX: Float = 0f + var stepVelocityY: Float = 0f + var stepVelocityZ: Float = 0f + + /** Where the particle was before it moved, so a collision can put it back. */ + var previousX: Float = 0f + var previousY: Float = 0f + var previousZ: Float = 0f + + init { + restart() + } + + /** Stops emitting; whatever is alive lives out its lifetime, unless [immediate] drops it at once. */ + override fun stop(immediate: Boolean) { + if (immediate) particles.clear() + stopping = true + } + + /** + * Puts the emitter back where it was when the effect was created. + */ + override fun restart() { + particles.clear() + subInput?.events?.clear() + random = Random(seed) + context.rng = random + context.variables.clear() + age = 0f + sleepLeft = 0f + born = 0 + stopping = false + spawnDebt = spec.emission.burst.toFloat() + rollEmitterRandoms() + } + + private fun rollEmitterRandoms() { + repeat(4) { context.variables["emitter_random_${it + 1}"] = random.nextFloat() } + } + + /** + * Recomputes how the emitter maps to what is drawn, after the node has placed its frame. + */ + override fun place() { + when (spec.space) { + VfxSimulationSpace.LOCAL -> { + frame.toFollowedMatrix(simToRender, spec.inheritRotation, spec.inheritScale) + spawnOrigin.set(Vec3f.ZERO) + spawnRotation = if (spec.inheritRotation) QuatF.IDENTITY else frame.rotation + spawnScale.set(if (spec.inheritScale) Vec3f.ONES else frame.scale) + } + + VfxSimulationSpace.WORLD -> { + val offset = instance.originMinusAnchor + simToRender.setIdentity().translate(-offset.x, -offset.y, -offset.z) + spawnOrigin.set(frame.position).add(offset) + spawnRotation = frame.rotation + spawnScale.set(frame.scale) + } + } + subInput?.place(simToRender) + } + + /** + * Advances the emitter by [dt] seconds, split into steps no longer than the emitter asked for. + */ + override fun update(dt: Float) { + if (dt <= 0f) return + + val maxStep = spec.maxStep.coerceAtLeast(MIN_STEP) + val steps = ceil(dt / maxStep).toInt().coerceIn(1, MAX_SUBSTEPS) + val step = dt / steps + repeat(steps) { advance(step) } + } + + private fun advance(dt: Float) { + age += dt + + val input = subInput + if (input != null) { + if (enabled && !stopping) spawnEvents(input) + input.events.clear() + } else { + advanceEmission(dt) + } + + fillEmitterContext() + modules.forEach { it.beginStep(this, dt) } + simulate(dt) + } + + private fun advanceEmission(dt: Float) { + if (sleepLeft > 0f) { + sleepLeft -= dt + if (sleepLeft <= 0f) startLoop() + } else if (isExpired()) { + val emission = spec.emission + if (emission.loop && !stopping) { + sleepLeft = emission.loopDelay + if (sleepLeft <= 0f) startLoop() + } + } + + if (enabled && !stopping && !isExpired() && sleepLeft <= 0f) emit(dt) + } + + /** Spawns what the emitter this one listens to handed over since the last step. */ + private fun spawnEvents(input: VfxSubEmitterInput) { + val events = input.events + for (event in 0 until events.size) { + repeat(events.count(event)) { + if (!spawn(input, event)) return + } + } + } + + private fun startLoop() { + age = 0f + born = 0 + spawnDebt += spec.emission.burst.toFloat() + rollEmitterRandoms() + } + + private fun isExpired(): Boolean { + val duration = spec.emission.duration + return duration > 0f && age >= duration + } + + private fun emit(dt: Float) { + fillEmitterContext() + val perSecond = rate.eval(context) + if (perSecond > 0f) spawnDebt += perSecond * dt + + while (spawnDebt >= 1f) { + spawnDebt -= 1f + if (!spawn()) { + spawnDebt = 0f + return + } + } + } + + /** + * Puts one particle into the world. Returns false when the emitter is full. A sub-emitter spawns + * around [event] of [input] instead of around its own origin. + */ + private fun spawn(input: VfxSubEmitterInput? = null, event: Int = -1): Boolean { + if (!instance.requestParticle()) return false + + val slot = particles.allocate() + if (slot < 0) return false + + fillEmitterContext() + val events = input?.events + context.particleRandom = random.nextFloat() + context.parentRandom = events?.random(event) ?: 0f + context.particleIndex = born.toFloat() + context.age = 0f + context.progress = 0f + context.speed = 0f + + val life = lifetime.eval(context).coerceAtLeast(MIN_LIFETIME) + context.lifetime = life + + shape.pick(scratchPosition, scratchDirection, context, random) + offset.eval(context, scratchVector) + scratchPosition.add(scratchVector) + + val launch = speed.eval(context) + scratchDirection.set( + scratchDirection.x * launch, + scratchDirection.y * launch, + scratchDirection.z * launch, + ) + + applySpawnSpace(scratchPosition, point = events == null) + applySpawnSpace(scratchDirection, point = false) + if (events != null) { + scratchPosition.set( + scratchPosition.x + events.x(event), + scratchPosition.y + events.y(event), + scratchPosition.z + events.z(event), + ) + scratchDirection.set( + scratchDirection.x + events.velocityX(event), + scratchDirection.y + events.velocityY(event), + scratchDirection.z + events.velocityZ(event), + ) + } + + particles.positionX[slot] = scratchPosition.x + particles.positionY[slot] = scratchPosition.y + particles.positionZ[slot] = scratchPosition.z + + val inherit = if (events == null) inheritVelocity.eval(context) else 0f + particles.velocityX[slot] = scratchDirection.x + instance.velocityX * inherit + particles.velocityY[slot] = scratchDirection.y + instance.velocityY * inherit + particles.velocityZ[slot] = scratchDirection.z + instance.velocityZ * inherit + + particles.age[slot] = 0f + particles.lifetime[slot] = life + particles.random[slot] = context.particleRandom + particles.index[slot] = context.particleIndex + particles.parentRandom[slot] = context.parentRandom + particles.frame[slot] = 0f + + looks.forEach { it.spawn(slot, context) } + + particles.light[slot] = instance.lightAt(this, slot) + + born++ + modules.forEach { it.onSpawn(this, slot) } + fire(VfxParticleEvent.SPAWN, slot, 0f) + return true + } + + /** Hands the particle in [slot] to the sub-emitters that listen for [event]. */ + private fun fire(event: VfxParticleEvent, slot: Int, dt: Float) { + subEmitters.forEach { it.take(event, slot, dt) } + } + + /** Called by a module when the particle in [slot] hits a block. */ + fun particleCollided(slot: Int, dt: Float) { + if (subEmitters.isNotEmpty()) fire(VfxParticleEvent.COLLISION, slot, dt) + } + + private fun die(slot: Int, dt: Float) { + if (subEmitters.isNotEmpty()) { + fillParticleContext(slot) + fire(VfxParticleEvent.DEATH, slot, dt) + } + particles.kill(slot) + } + + private fun simulate(dt: Float) { + var slot = 0 + while (slot < particles.count) { + particles.age[slot] += dt + if (particles.age[slot] >= particles.lifetime[slot]) { + die(slot, dt) + continue + } + + fillParticleContext(slot) + + stepVelocityX = 0f + stepVelocityY = 0f + stepVelocityZ = 0f + modules.forEach { it.onStep(this, slot, dt) } + + val fall = gravity.eval(context) + if (fall != 0f) particles.velocityY[slot] -= fall * dt + + val friction = drag.eval(context) + if (friction > 0f) { + val keep = (1f - friction * dt).coerceIn(0f, 1f) + particles.velocityX[slot] *= keep + particles.velocityY[slot] *= keep + particles.velocityZ[slot] *= keep + } + + previousX = particles.positionX[slot] + previousY = particles.positionY[slot] + previousZ = particles.positionZ[slot] + + particles.positionX[slot] += (particles.velocityX[slot] + stepVelocityX) * dt + particles.positionY[slot] += (particles.velocityY[slot] + stepVelocityY) * dt + particles.positionZ[slot] += (particles.velocityZ[slot] + stepVelocityZ) * dt + + modules.forEach { it.onMoved(this, slot, dt) } + + if (slot < particles.count && particles.age[slot] >= particles.lifetime[slot]) { + die(slot, dt) + continue + } + + looks.forEach { it.step(slot, context) } + if (subEmitters.isNotEmpty()) fire(VfxParticleEvent.ALIVE, slot, dt) + + if (instance.readsLight) particles.light[slot] = instance.lightAt(this, slot) + slot++ + } + } + + /** Fills context with what does not depend on a particular particle. */ + fun fillEmitterContext() { + context.emitterAge = age + context.emitterCount = particles.count.toFloat() + context.effectTime = instance.time + context.partialTick = instance.partialTick + context.gameTime = instance.gameTime + context.data = instance.data + } + + /** Fills context for one particle, which is what every sampler reads. */ + fun fillParticleContext(slot: Int) { + fillEmitterContext() + val life = particles.lifetime[slot] + context.age = particles.age[slot] + context.lifetime = life + context.progress = if (life > 0f) (particles.age[slot] / life).coerceIn(0f, 1f) else 1f + context.positionX = particles.positionX[slot] + context.positionY = particles.positionY[slot] + context.positionZ = particles.positionZ[slot] + val vx = particles.velocityX[slot] + val vy = particles.velocityY[slot] + val vz = particles.velocityZ[slot] + context.velocityX = vx + context.velocityY = vy + context.velocityZ = vz + context.speed = sqrt(vx * vx + vy * vy + vz * vz) + context.particleRandom = particles.random[slot] + context.particleIndex = particles.index[slot] + context.parentRandom = particles.parentRandom[slot] + } + + /** A particle position in effect space, which is what the world is read in. */ + fun toEffectSpace(slot: Int, into: MutableVec3f): MutableVec3f { + into.set(particles.positionX[slot], particles.positionY[slot], particles.positionZ[slot]) + return simToRender.transform(into, 1f) + } + + /** Turns a direction of effect space into the space this emitter simulates in. */ + fun toSimDirection(direction: Vec3f, into: MutableVec3f): MutableVec3f = when (spec.space) { + VfxSimulationSpace.WORLD -> into.set(direction) + VfxSimulationSpace.LOCAL -> { + val turned = direction.rotateBy(frame.rotation.inverted()) + into.set(turned.x, turned.y, turned.z) + } + } + + /** A point of the emitter frame, in the space particles live in; how a module places a force. */ + fun nodeToSimPoint(point: Vec3f, into: MutableVec3f): MutableVec3f { + into.set(point) + applySpawnSpace(into, point = true) + return into + } + + fun nodeToSimDirection(direction: Vec3f, into: MutableVec3f): MutableVec3f { + into.set(direction) + applySpawnSpace(into, point = false) + return into + } + + /** Moves a birth position or direction out of node space into the space particles live in. */ + private fun applySpawnSpace(vector: MutableVec3f, point: Boolean) { + vector.set(vector.x * spawnScale.x, vector.y * spawnScale.y, vector.z * spawnScale.z) + if (spawnRotation !== QuatF.IDENTITY) { + val rotated = (vector as Vec3f).rotateBy(spawnRotation) + vector.set(rotated.x, rotated.y, rotated.z) + } + if (point) vector.add(spawnOrigin) + } + + fun noiseSeed(axis: Int): Int = seed + axis * 6151 + + private fun scalar( + property: VfxProperty, + value: VfxValue, + default: Float, + range: VfxRangeMode = VfxRangeMode.FRESH, + ): VfxFloatSampler = VfxSamplers.driven( + VfxSamplers.scalar(value, expressions, default, range, property.hashCode()), + node.drive(property), + ) + + private fun vector( + property: VfxProperty, + value: VfxVec3Value, + default: Float, + range: VfxRangeMode = VfxRangeMode.FRESH, + ): VfxVec3Sampler = VfxSamplers.vec3(value, expressions, default, range, property.hashCode(), node.drive(property)) + + companion object { + private const val MIN_STEP = 1f / 240f + private const val MAX_SUBSTEPS = 8 + private const val MIN_LIFETIME = 0.01f + + /** A ceiling on what one emitter may ask for, whatever the file says. */ + const val MAX_PARTICLES_PER_EMITTER = 20_000 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEmitterShape.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEmitterShape.kt new file mode 100644 index 000000000..4e84917e9 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEmitterShape.kt @@ -0,0 +1,111 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxDirectionMode +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxShape +import ru.hollowhorizon.hollowengine.common.vfx.VfxShapeKind +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan +import kotlin.random.Random + +/** + * Where on its shape an emitter puts a newborn particle, and which way it sends it, in node space. + */ +internal class VfxEmitterShape(private val shape: VfxShape, node: VfxNodeRuntime) { + private val radius = scalar(node, VfxProperty.SHAPE_RADIUS, shape.radius, 0f) + private val angle = scalar(node, VfxProperty.SHAPE_ANGLE, shape.angle, 0f) + private val thickness = scalar(node, VfxProperty.SHAPE_THICKNESS, shape.thickness, 1f) + private val extents = VfxSamplers.vec3( + shape.extents, node.expressions, 0f, VfxRangeMode.FRESH, + VfxProperty.SHAPE_EXTENTS.hashCode(), node.drive(VfxProperty.SHAPE_EXTENTS), + ) + + /** The surface a model shape spawns on; until it has loaded, particles start at the origin. */ + private val modelSurface = shape.model.takeIf { shape.kind == VfxShapeKind.MODEL && it.isNotBlank() } + ?.let(VfxModelSurfaces::request) + + private val scratch = MutableVec3f() + + /** Picks a spawn point on the shape and the way the particle leaves it. */ + fun pick(position: MutableVec3f, direction: MutableVec3f, context: VfxEvalContext, random: Random) { + position.set(Vec3f.ZERO) + direction.set(shape.direction) + + when (shape.kind) { + VfxShapeKind.POINT -> Unit + + VfxShapeKind.SPHERE -> { + randomDirection(direction, random) + val reach = radius.eval(context) * shellDepth(thickness.eval(context), random) + position.set(direction.x * reach, direction.y * reach, direction.z * reach) + } + + VfxShapeKind.BOX -> { + extents.eval(context, scratch) + position.set( + (random.nextFloat() * 2f - 1f) * scratch.x, + (random.nextFloat() * 2f - 1f) * scratch.y, + (random.nextFloat() * 2f - 1f) * scratch.z, + ) + } + + VfxShapeKind.CONE -> { + val around = random.nextFloat() * TAU + val spread = tan(angle.eval(context).coerceIn(0f, 89f) * DEG_TO_RAD) * random.nextFloat() + val reach = radius.eval(context) * sqrt(random.nextFloat()) * shellDepth(thickness.eval(context), random) + position.set(cos(around) * reach, 0f, sin(around) * reach) + direction.set(cos(around) * spread, 1f, sin(around) * spread).norm() + } + + VfxShapeKind.DISC -> { + val around = random.nextFloat() * TAU + val reach = radius.eval(context) * sqrt(random.nextFloat()) * shellDepth(thickness.eval(context), random) + position.set(cos(around) * reach, 0f, sin(around) * reach) + direction.set(position).norm() + if (direction.sqrLength() < 1.0e-6f) direction.set(shape.direction) + } + + VfxShapeKind.LINE -> { + extents.eval(context, scratch) + val along = random.nextFloat() + position.set(scratch.x * along, scratch.y * along, scratch.z * along) + } + + VfxShapeKind.MODEL -> modelSurface?.surface?.sample(random, position, direction) + } + + when (shape.directionMode) { + VfxDirectionMode.FIXED -> direction.set(shape.direction) + VfxDirectionMode.RANDOM -> randomDirection(direction, random) + VfxDirectionMode.SHAPE -> Unit + } + if (direction.sqrLength() < 1.0e-6f) direction.set(Vec3f.Y_AXIS) else direction.norm() + } + + private fun randomDirection(into: MutableVec3f, random: Random) { + val z = random.nextFloat() * 2f - 1f + val around = random.nextFloat() * TAU + val planar = sqrt((1f - z * z).coerceAtLeast(0f)) + into.set(cos(around) * planar, z, sin(around) * planar) + } + + private fun shellDepth(thickness: Float, random: Random): Float = + if (thickness <= 0f) 1f else 1f - random.nextFloat() * thickness.coerceAtMost(1f) + + private companion object { + const val TAU = (PI * 2.0).toFloat() + const val DEG_TO_RAD = (PI / 180.0).toFloat() + + fun scalar(node: VfxNodeRuntime, property: VfxProperty, value: VfxValue, default: Float) = + VfxSamplers.driven( + VfxSamplers.scalar(value, node.expressions, default, VfxRangeMode.FRESH, property.hashCode()), + node.drive(property), + ) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEnvironment.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEnvironment.kt new file mode 100644 index 000000000..a0f8b0225 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxEnvironment.kt @@ -0,0 +1,131 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import net.minecraft.client.renderer.LightTexture +import net.minecraft.core.BlockPos +import net.minecraft.world.level.Level +import net.minecraft.world.level.LightLayer +import kotlin.math.floor + +/** + * What an effect is allowed to ask about the place it is playing in. + */ +interface VfxEnvironment { + /** Whether a point is inside something a particle cannot pass through. */ + fun isSolid(x: Double, y: Double, z: Double): Boolean + + /** The packed block and skylight at a point, for materials that are lit by the world. */ + fun lightAt(x: Double, y: Double, z: Double): Int + + companion object { + /** An environment with nothing in it, for effects that neither collide nor read light. */ + val EMPTY = object : VfxEnvironment { + override fun isSolid(x: Double, y: Double, z: Double) = false + + override fun lightAt(x: Double, y: Double, z: Double) = LightTexture.FULL_BRIGHT + } + } +} + +/** + * The world an effect is playing in. + */ +class VfxWorldEnvironment(private val level: Level) : VfxEnvironment { + private val cursor = BlockPos.MutableBlockPos() + + override fun isSolid(x: Double, y: Double, z: Double): Boolean { + cursor.set(floor(x).toInt(), floor(y).toInt(), floor(z).toInt()) + if (!level.hasChunkAt(cursor)) return false + + val state = level.getBlockState(cursor) + if (state.isAir) return false + + val shape = state.getCollisionShape(level, cursor) + if (shape.isEmpty) return false + + val bounds = shape.bounds() + val localX = x - cursor.x + val localY = y - cursor.y + val localZ = z - cursor.z + return localX in bounds.minX..bounds.maxX && localY in bounds.minY..bounds.maxY && localZ in bounds.minZ..bounds.maxZ + } + + override fun lightAt(x: Double, y: Double, z: Double): Int { + cursor.set(floor(x).toInt(), floor(y).toInt(), floor(z).toInt()) + if (!level.hasChunkAt(cursor)) return LightTexture.FULL_BRIGHT + return LightTexture.pack( + level.getBrightness(LightLayer.BLOCK, cursor), + level.getBrightness(LightLayer.SKY, cursor), + ) + } +} + +/** + * The editor preview, a floor at [floorY] and nothing else. + */ +class VfxPlaneEnvironment(private val floorY: Double = 0.0) : VfxEnvironment { + override fun isSolid(x: Double, y: Double, z: Double): Boolean = y < floorY + + override fun lightAt(x: Double, y: Double, z: Double): Int = LightTexture.FULL_BRIGHT +} + +/** + * Smooth value noise, sampled in three dimensions and scrolling in a fourth. + */ +object VfxNoise { + fun sample(x: Float, y: Float, z: Float, seed: Int): Float { + val xi = floor(x.toDouble()).toInt() + val yi = floor(y.toDouble()).toInt() + val zi = floor(z.toDouble()).toInt() + val xf = smooth(x - xi) + val yf = smooth(y - yi) + val zf = smooth(z - zi) + + val c000 = hash(xi, yi, zi, seed) + val c100 = hash(xi + 1, yi, zi, seed) + val c010 = hash(xi, yi + 1, zi, seed) + val c110 = hash(xi + 1, yi + 1, zi, seed) + val c001 = hash(xi, yi, zi + 1, seed) + val c101 = hash(xi + 1, yi, zi + 1, seed) + val c011 = hash(xi, yi + 1, zi + 1, seed) + val c111 = hash(xi + 1, yi + 1, zi + 1, seed) + + val x00 = c000 + (c100 - c000) * xf + val x10 = c010 + (c110 - c010) * xf + val x01 = c001 + (c101 - c001) * xf + val x11 = c011 + (c111 - c011) * xf + val y0 = x00 + (x10 - x00) * yf + val y1 = x01 + (x11 - x01) * yf + return y0 + (y1 - y0) * zf + } + + /** [octaves] layers of finer, weaker detail on top of each other. */ + fun fractal(x: Float, y: Float, z: Float, seed: Int, octaves: Int): Float { + if (octaves <= 1) return sample(x, y, z, seed) + + var amplitude = 1f + var frequency = 1f + var total = 0f + var normalizer = 0f + repeat(octaves.coerceAtMost(4)) { octave -> + total += sample(x * frequency, y * frequency, z * frequency, seed + octave * 7919) * amplitude + normalizer += amplitude + amplitude *= 0.5f + frequency *= 2f + } + return total / normalizer + } + + private fun smooth(t: Float): Float = t * t * (3f - 2f * t) + + /** A value in -1 to 1, stable for the same cell and seed. */ + private fun hash(x: Int, y: Int, z: Int, seed: Int): Float { + var h = seed + h = h * 374761393 + x * 668265263 + h = h * 374761393 + y * 2147483647 + h = h * 374761393 + z * 1274126177 + h = h xor (h shr 13) + h *= 1274126177 + h = h xor (h shr 16) + return (h and 0xFFFF) / 32768f - 1f + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxExpressions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxExpressions.kt new file mode 100644 index 000000000..8010cff3b --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxExpressions.kt @@ -0,0 +1,192 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.utils.expressions.Coercions +import ru.hollowhorizon.hollowengine.common.utils.expressions.CompiledUnit +import ru.hollowhorizon.hollowengine.common.utils.expressions.Declarations +import ru.hollowhorizon.hollowengine.common.utils.expressions.Expression +import ru.hollowhorizon.hollowengine.common.utils.expressions.References +import ru.hollowhorizon.hollowengine.common.utils.expressions.mathNamespace +import kotlin.random.Random + +/** + * What an effect expression can see. + */ +class VfxEvalContext { + /** Random source of the emitter, so a seeded effect stays reproducible. */ + var rng: Random = Random.Default + + var age: Float = 0f + var lifetime: Float = 1f + + /** Age over lifetime, 0 to 1. */ + var progress: Float = 0f + + var positionX: Float = 0f + var positionY: Float = 0f + var positionZ: Float = 0f + + var velocityX: Float = 0f + var velocityY: Float = 0f + var velocityZ: Float = 0f + var speed: Float = 0f + + /** A number fixed when the particle was born, for per-particle variation that does not flicker. */ + var particleRandom: Float = 0f + + /** The index of the particle in its emitter, which is also its spawn order. */ + var particleIndex: Float = 0f + + /** The [particleRandom] of the particle a sub-emitter spawned this one from; 0 for any other. */ + var parentRandom: Float = 0f + + var emitterAge: Float = 0f + var emitterCount: Float = 0f + var effectTime: Float = 0f + var partialTick: Float = 0f + var gameTime: Float = 0f + + /** Blocks from the camera to the node reading the value. */ + var cameraDistance: Float = 0f + + /** The numeric leaves of the handle data store by path, refilled when the store changes. */ + var data: Map = emptyMap() + + /** Values an expression may write and read back later; they live as long as the effect. */ + val variables = HashMap() + + /** Scratch space, cleared between steps. */ + val temporaries = HashMap() + + /** Takes every input of [other]; the variables stay this context's own. */ + fun copyInputs(other: VfxEvalContext) { + rng = other.rng + age = other.age + lifetime = other.lifetime + progress = other.progress + positionX = other.positionX + positionY = other.positionY + positionZ = other.positionZ + velocityX = other.velocityX + velocityY = other.velocityY + velocityZ = other.velocityZ + speed = other.speed + particleRandom = other.particleRandom + particleIndex = other.particleIndex + parentRandom = other.parentRandom + emitterAge = other.emitterAge + emitterCount = other.emitterCount + effectTime = other.effectTime + partialTick = other.partialTick + gameTime = other.gameTime + cameraDistance = other.cameraDistance + data = other.data + } +} + +val VfxDeclarations: Declarations = Declarations { + val particle = struct("particle") { + float("age") { it.age } + float("lifetime") { it.lifetime } + float("progress", alias = "t") { it.progress } + float("x") { it.positionX } + float("y") { it.positionY } + float("z") { it.positionZ } + float("velocity_x", alias = "vx") { it.velocityX } + float("velocity_y", alias = "vy") { it.velocityY } + float("velocity_z", alias = "vz") { it.velocityZ } + float("speed") { it.speed } + float("random") { it.particleRandom } + float("index") { it.particleIndex } + float("parent_random") { it.parentRandom } + } + + val emitter = struct("emitter") { + float("age") { it.emitterAge } + float("count") { it.emitterCount } + float("time") { it.effectTime } + float("partial_tick") { it.partialTick } + float("game_time") { it.gameTime } + float("random") { it.rng.nextFloat() } + } + + val node = struct("node") { + float("camera_distance") { it.cameraDistance } + } + + val math = mathNamespace() + + val variables = dynamic( + name = "variables", + read = { owner, key -> (owner as VfxEvalContext).variables[key] ?: 0f }, + write = { owner, key, value -> + (owner as VfxEvalContext).variables[key] = Coercions.toFloat(value) + }, + ) + + val temporaries = dynamic( + name = "temporaries", + read = { owner, key -> (owner as VfxEvalContext).temporaries[key] ?: 0f }, + write = { owner, key, value -> + (owner as VfxEvalContext).temporaries[key] = Coercions.toFloat(value) + }, + ) + + val handleData = dynamic( + name = "data", + read = { owner, key -> (owner as VfxEvalContext).data[key] ?: 0f }, + nested = true, + ) + + property("particle", particle, alias = "p") { it } + property("emitter", emitter, alias = "e") { it } + property("node", node, alias = "n") { it } + property("math", math) { it } + property("variable", variables, alias = "v") { it } + property("temp", temporaries, alias = "t") { it } + property("data", handleData, alias = "d") { it } + receiver("math") +} + +/** + * The VFX dialect. + * + * An unknown name warns and reads as zero rather than failing the file: an effect that mentions a + * data key the server has not written yet still has to play. + */ +val VfxExpressionLanguage: Expression = Expression { + options { unresolvedReferences(References.warnWithDefault(0f)) } + declarations(VfxDeclarations) +} + +/** + * Every expression of one `.vfx` file, compiled together. + */ +class VfxExpressions private constructor(private val unit: CompiledUnit?) { + fun float(source: String, default: Float = 0f): (VfxEvalContext) -> Float { + val unit = unit ?: return { default } + val compiled = unit.float(source, default) + return compiled::eval + } + + companion object { + val EMPTY = VfxExpressions(null) + + private val cache = HashMap, VfxExpressions>() + + fun compile(sources: Collection, name: String): VfxExpressions { + val wanted = sources.filter { it.isNotBlank() }.distinct().sorted() + if (wanted.isEmpty()) return EMPTY + + cache[wanted]?.let { return it } + + val unit = VfxExpressionLanguage.compile(wanted) + unit.diagnostics.forEach { HollowEngine.LOGGER.warn("Effect {}: {}", name, it) } + + if (cache.size >= CACHE_LIMIT) cache.clear() + return VfxExpressions(unit).also { cache[wanted] = it } + } + + private const val CACHE_LIMIT = 64 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxFrame.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxFrame.kt new file mode 100644 index 000000000..06202f341 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxFrame.kt @@ -0,0 +1,93 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.client.utils.math.rotateBy +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableQuatF +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +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 ru.hollowhorizon.hollowengine.common.vfx.VfxTransform + +/** + * Where a node sits, kept as position, rotation and scale rather than a matrix. + */ +class VfxFrame { + val position = MutableVec3f() + val rotation = MutableQuatF() + val scale = MutableVec3f(Vec3f.ONES) + + fun setIdentity(): VfxFrame { + position.set(Vec3f.ZERO) + rotation.setIdentity() + scale.set(Vec3f.ONES) + return this + } + + fun set(other: VfxFrame): VfxFrame { + position.set(other.position) + rotation.set(other.rotation) + scale.set(other.scale) + return this + } + + /** This frame becomes [parent] with [local] applied inside it. */ + fun setCombined(parent: VfxFrame, local: VfxTransform): VfxFrame { + val offset = MutableVec3f( + local.position.x * parent.scale.x, + local.position.y * parent.scale.y, + local.position.z * parent.scale.z, + ).rotateInPlace(parent.rotation) + + position.set(parent.position).add(offset) + parent.rotation.mul(eulerOf(local.rotation), rotation) + scale.set( + parent.scale.x * local.scale.x, + parent.scale.y * local.scale.y, + parent.scale.z * local.scale.z, + ) + return this + } + + /** A point of this frame, in whatever space the frame itself is expressed in. */ + fun transformPoint(point: Vec3f, into: MutableVec3f): MutableVec3f { + val scaled = MutableVec3f(point.x * scale.x, point.y * scale.y, point.z * scale.z) + .rotateInPlace(rotation) + return into.set(position).add(scaled) + } + + /** A direction of this frame: rotated and scaled, but not moved. */ + fun transformDirection(direction: Vec3f, into: MutableVec3f): MutableVec3f { + val scaled = MutableVec3f( + direction.x * scale.x, + direction.y * scale.y, + direction.z * scale.z, + ).rotateInPlace(rotation) + return into.set(scaled) + } + + fun toMatrix(into: MutableMat4f): MutableMat4f = + into.setIdentity().translate(position).rotate(rotation).scale(scale) + + /** The part of this frame particles follow, once the author has said what they follow. */ + fun toFollowedMatrix(into: MutableMat4f, rotation: Boolean, scale: Boolean): MutableMat4f { + into.setIdentity().translate(position) + if (rotation) into.rotate(this.rotation) + if (scale) into.scale(this.scale) + return into + } + + private fun MutableVec3f.rotateInPlace(quaternion: QuatF): MutableVec3f { + val rotated = (this as Vec3f).rotateBy(quaternion) + return set(rotated.x, rotated.y, rotated.z) + } + + companion object { + /** Euler degrees as a quaternion, applied X then Y then Z. */ + fun eulerOf(euler: Vec3f): QuatF = MutableQuatF() + .setIdentity() + .rotate(euler.x.deg, Vec3f.X_AXIS) + .rotate(euler.y.deg, Vec3f.Y_AXIS) + .rotate(euler.z.deg, Vec3f.Z_AXIS) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxInstance.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxInstance.kt new file mode 100644 index 000000000..cc39c20d8 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxInstance.kt @@ -0,0 +1,310 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import net.minecraft.world.phys.Vec3 +import org.joml.Matrix4f +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxDrawList +import ru.hollowhorizon.hollowengine.common.data.NbtDataStore +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.* +import kotlin.random.Random + +/** + * One playing effect. + * + * Time comes from the caller rather than from a clock of its own, because the same instance plays in + * the world off the game clock and in the editor off a scrubbing timeline. + */ +class VfxInstance( + val effect: VfxEffect, + val asset: String, + val environment: VfxEnvironment = VfxEnvironment.EMPTY, + seed: Int = Random.nextInt(), +) { + private val expressions = VfxExpressions.compile(effect.expressions(), asset) + + /** Which properties of which node have a track, and how many channels each carries. */ + private val driven: Map> = + effect.timeline.tracks.groupBy { it.node }.mapValues { (nodeId, tracks) -> + val node = effect.node(nodeId) + tracks.mapNotNull { track -> + if (node == null || track.curves.none { it.keys.isNotEmpty() }) return@mapNotNull null + val animatable = VfxAnimatables.of(node, track.property) ?: return@mapNotNull null + track.property to animatable.kind.channels + }.toMap() + } + + internal fun drivenProperties(nodeId: String): Map = driven[nodeId].orEmpty() + + /** The frame the whole effect sits in; a bone or a moving entity writes into it. */ + val root = VfxFrame() + + /** Where effect space sits in the world. */ + var origin: Vec3 = Vec3.ZERO + private set + + /** Where world-space particles are measured from; fixed, so they do not follow a moving origin. */ + var anchor: Vec3 = Vec3.ZERO + private set + + /** [origin] minus [anchor], the only part of the two a float can safely hold. */ + val originMinusAnchor = MutableVec3f() + + /** Where the camera is, in effect space; whoever plays the effect keeps it current. */ + val camera = MutableVec3f() + + val nodes: List = effect.nodes.mapIndexed { index, node -> + VfxNodeRuntime(node, null, this, expressions, seed + index * 7919, drivenProperties(node.id)) + } + + /** Every node, parents before children, which is also the order they update and draw in. */ + val allNodes: List = nodes.flatMap { it.walk() } + + private val behaviors: List = allNodes.mapNotNull { it.behavior } + + val emitters: List = behaviors.filterIsInstance() + + private val timeline = VfxTimelinePlayer(effect.timeline, allNodes) + + /** Seconds since the effect started playing. */ + var time: Float = 0f + private set + + /** Where the timeline is, looped when the timeline loops. */ + val timelineTime: Float get() = timeline.localTime(time) + + /** How fast the origin is moving, for particles that inherit it. */ + var velocityX: Float = 0f + private set + var velocityY: Float = 0f + private set + var velocityZ: Float = 0f + private set + + var partialTick: Float = 0f + var gameTime: Float = 0f + + /** The data store of the handle, flattened to the numbers expressions read as `d.`. */ + var data: Map = emptyMap() + private set + + /** Whether anything here is lit by the world, so light is only looked up when it is used. */ + val readsLight: Boolean = + effect.walk().filterIsInstance().any { it.material.lighting == VfxLighting.WORLD } || + effect.walk().any { it is VfxModelSpec && !it.emissive } + + var isStopping: Boolean = false + private set + + /** + * Whether everything has stopped and nothing is left on screen. Without emitters, an effect + * ends with a timeline that does not loop. + */ + val isFinished: Boolean + get() = when { + isStopping -> behaviors.all { it.isIdle } + emitters.isNotEmpty() -> emitters.all { it.isFinished } + else -> !effect.timeline.loop && time >= effect.timeline.duration + } + + /** How many particles this effect may still create this frame; the scene refills it. */ + var budget: Int = Int.MAX_VALUE + + val particleCount: Int get() = emitters.sumOf { it.particles.count } + + init { + moveTo(Vec3.ZERO, resetAnchor = true) + } + + fun readData(store: NbtDataStore) { + data = store.numericPaths() + } + + /** Puts effect space somewhere in the world. The anchor only moves when asked. */ + fun moveTo(position: Vec3, resetAnchor: Boolean = false) { + if (resetAnchor) anchor = position + origin = position + originMinusAnchor.set( + (origin.x - anchor.x).toFloat(), + (origin.y - anchor.y).toFloat(), + (origin.z - anchor.z).toFloat(), + ) + } + + /** Remembers where the camera is in the world, for distances and camera-facing ribbons. */ + fun setCamera(world: Vec3) { + camera.set((world.x - origin.x).toFloat(), (world.y - origin.y).toFloat(), (world.z - origin.z).toFloat()) + } + + /** Remembers how fast the origin is moving, for inherited velocity. */ + fun setVelocity(x: Float, y: Float, z: Float) { + velocityX = x + velocityY = y + velocityZ = z + } + + fun stop(immediate: Boolean) { + isStopping = true + behaviors.forEach { it.stop(immediate) } + } + + /** Advances everything by [dt] seconds and leaves the nodes ready to draw. */ + fun update(dt: Float) { + if (dt > 0f) time += dt + pose() + behaviors.forEach { it.update(dt) } + } + + /** Places every node for the current time: the authored transform, then the timeline over it. */ + private fun pose() { + nodes.forEach { it.reset() } + if (!timeline.isEmpty) timeline.apply(time) + nodes.forEach { it.place(root) } + allNodes.forEach { it.fillContext() } + } + + /** Adds what every active node draws; [placement] maps effect space to the space of the view. */ + fun collect(into: VfxDrawList, placement: Matrix4f) { + allNodes.forEach { node -> node.behavior?.collect(into, placement) } + } + + fun node(id: String): VfxNodeRuntime? = allNodes.firstOrNull { it.spec.id == id } + + /** + * Takes the placement of [next] - where nodes are, whether they are on, what they are called - + * without building the effect again. Returns false when [next] differs in anything else. + */ + fun adoptPlacement(next: VfxEffect): Boolean { + if (next.withoutPlacement() != effect.withoutPlacement()) return false + allNodes.forEach { node -> + val spec = next.node(node.spec.id) ?: return@forEach + node.authoredTransform = spec.transform + node.authoredEnabled = spec.enabled + } + pose() + return true + } + + /** + * Back to the first moment. No particles, every emitter about to start its first loop, and + * nodes where timeline has them at zero. + */ + fun restart() { + time = 0f + isStopping = false + behaviors.forEach { it.restart() } + pose() + } + + /** Whether one more particle fits in the global budget. */ + fun requestParticle(): Boolean { + if (budget <= 0) return false + budget-- + return true + } + + /** The packed light at a particle, or full bright when nothing here is lit by the world. */ + fun lightAt(emitter: VfxEmitter, slot: Int): Int { + if (!readsLight) return FULL_BRIGHT + val point = emitter.toEffectSpace(slot, lightCursor) + return environment.lightAt(origin.x + point.x, origin.y + point.y, origin.z + point.z) + } + + /** The packed light at a point of effect space. */ + fun lightAt(point: Vec3f): Int { + if (!readsLight) return FULL_BRIGHT + return environment.lightAt(origin.x + point.x, origin.y + point.y, origin.z + point.z) + } + + private val lightCursor = MutableVec3f() + + companion object { + const val FULL_BRIGHT = 0xF000F0 + } +} + +/** + * Plays the timeline of an effect onto its live nodes. + * + * Curves are sampled the same way the curve editor draws them. + */ +class VfxTimelinePlayer(private val spec: VfxTimelineSpec, nodes: List) { + /** Curves paired with the channel they drive, resolved once. */ + private class PreparedTrack( + val node: VfxNodeRuntime, + val property: VfxProperty, + val channels: Int, + val defaults: FloatArray, + val curves: List>, + val drive: VfxDrive?, + ) + + private val tracks: List + private val values = FloatArray(4) + + val isEmpty: Boolean get() = tracks.isEmpty() + + init { + val byId = nodes.associateBy { it.spec.id } + tracks = spec.tracks.mapNotNull { track -> prepare(track, byId) } + } + + fun localTime(time: Float): Float = if (spec.loop && spec.duration > 0f) time.mod(spec.duration) else time + + fun apply(time: Float) { + val local = localTime(time) + tracks.forEach { track -> apply(track, local) } + } + + private fun prepare(track: VfxTrack, byId: Map): PreparedTrack? { + val node = byId[track.node] ?: return null + val property = VfxAnimatables.of(node.spec, track.property) ?: return null + val channels = property.kind.channels + + val curves = track.curves.filter { it.visible && it.channel in 0 until channels && it.keys.isNotEmpty() } + .map { it.channel to VfxCurve(it.keys) } + if (curves.isEmpty()) return null + + val drive = node.drive(track.property) + drive?.let { target -> curves.forEach { (channel, _) -> target.active[channel] = true } } + + return PreparedTrack(node, track.property, channels, property.read(), curves, drive) + } + + private fun apply(track: PreparedTrack, time: Float) { + for (channel in 0 until track.channels) { + values[channel] = track.defaults.getOrElse(channel) { 0f } + } + + val placement = when (track.property) { + VfxProperty.POSITION -> track.node.transform.position + VfxProperty.ROTATION -> track.node.transform.rotation + VfxProperty.SCALE -> track.node.transform.scale + else -> null + } + placement?.let { + values[0] = it.x + values[1] = it.y + values[2] = it.z + } + track.curves.forEach { (channel, curve) -> + values[channel] = curve.valueAt(time, values[channel]) + } + + when (track.property) { + VfxProperty.ENABLED -> track.node.enabled = values[0] >= 0.5f + VfxProperty.POSITION -> track.node.transform = + track.node.transform.copy(position = Vec3f(values[0], values[1], values[2])) + + VfxProperty.ROTATION -> track.node.transform = + track.node.transform.copy(rotation = Vec3f(values[0], values[1], values[2])) + + VfxProperty.SCALE -> track.node.transform = + track.node.transform.copy(scale = Vec3f(values[0], values[1], values[2])) + + else -> track.drive?.let { drive -> + for (channel in 0 until track.channels) drive.values[channel] = values[channel] + } + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxModelSurfaces.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxModelSurfaces.kt new file mode 100644 index 000000000..777ec1f39 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxModelSurfaces.kt @@ -0,0 +1,139 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.models.internal.Model +import ru.hollowhorizon.hollowengine.client.models.internal.NodeDefinition +import ru.hollowhorizon.hollowengine.client.models.internal.manager.HollowModelManager +import ru.hollowhorizon.hollowengine.common.coroutines.scopeAsync +import ru.hollowhorizon.hollowengine.common.utils.math.Mat4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import kotlin.math.sqrt +import kotlin.random.Random + +/** + * The triangles of a model in its bind pose, ready to pick points on by area. + */ +class VfxModelSurface( + private val corners: FloatArray, + private val areas: FloatArray, +) { + val isEmpty: Boolean get() = areas.isEmpty() + + /** A point spread evenly over the surface, and the outward normal of the triangle it is on. */ + fun sample(random: Random, position: MutableVec3f, normal: MutableVec3f) { + if (isEmpty) return + + val wanted = random.nextFloat() * areas.last() + var low = 0 + var high = areas.lastIndex + while (low < high) { + val middle = (low + high) ushr 1 + if (areas[middle] < wanted) low = middle + 1 else high = middle + } + + var u = random.nextFloat() + var v = random.nextFloat() + if (u + v > 1f) { + u = 1f - u + v = 1f - v + } + val at = low * 9 + val ax = corners[at] + val ay = corners[at + 1] + val az = corners[at + 2] + val abx = corners[at + 3] - ax + val aby = corners[at + 4] - ay + val abz = corners[at + 5] - az + val acx = corners[at + 6] - ax + val acy = corners[at + 7] - ay + val acz = corners[at + 8] - az + + position.set(ax + abx * u + acx * v, ay + aby * u + acy * v, az + abz * u + acz * v) + normal.set(aby * acz - abz * acy, abz * acx - abx * acz, abx * acy - aby * acx) + if (normal.sqrLength() > 1.0e-12f) normal.norm() + } + + companion object { + /** Every triangle of every mesh of [model], each moved by the bind pose of its node. */ + fun of(model: Model): VfxModelSurface { + val corners = ArrayList() + val scene = model.scenes.getOrNull(model.scene) ?: model.scenes.firstOrNull() + scene?.nodes?.forEach { root -> collect(root, Mat4f.IDENTITY, corners) } + + val packed = corners.toFloatArray() + val areas = FloatArray(packed.size / 9) + var total = 0f + for (triangle in areas.indices) { + total += area(packed, triangle * 9) + areas[triangle] = total + } + return VfxModelSurface(packed, areas) + } + + private fun collect(node: NodeDefinition, parent: Mat4f, into: MutableList) { + val matrix = parent.mul(node.localMatrix, MutableMat4f()) + node.mesh?.primitives?.forEach { primitive -> + val positions = primitive.positions ?: return@forEach + val indices = primitive.indices ?: IntArray(positions.size) { it } + val point = MutableVec3f() + for (index in 0 until indices.size - indices.size % 3) { + val vertex = positions.getOrNull(indices[index]) ?: continue + matrix.transform(vertex, 1f, point) + into += point.x + into += point.y + into += point.z + } + } + node.children.forEach { collect(it, matrix, into) } + } + + private fun area(corners: FloatArray, at: Int): Float { + val abx = corners[at + 3] - corners[at] + val aby = corners[at + 4] - corners[at + 1] + val abz = corners[at + 5] - corners[at + 2] + val acx = corners[at + 6] - corners[at] + val acy = corners[at + 7] - corners[at + 1] + val acz = corners[at + 8] - corners[at + 2] + val cx = aby * acz - abz * acy + val cy = abz * acx - abx * acz + val cz = abx * acy - aby * acx + return sqrt(cx * cx + cy * cy + cz * cz) * 0.5f + } + } +} + +/** + * The model surfaces spawn shapes use, loaded once per model in the background. + */ +object VfxModelSurfaces { + /** A surface on its way; [surface] is null until it has loaded, and stays null if it failed. */ + class Request { + @Volatile + var surface: VfxModelSurface? = null + internal set + } + + private val requests = HashMap() + + @Synchronized + fun request(model: String): Request = requests.getOrPut(model) { + Request().also { request -> load(model, request) } + } + + /** Models change with resource packs; the next request loads them again. */ + @Synchronized + fun clear() = requests.clear() + + private fun load(model: String, request: Request) { + val location = ResourceLocation.tryParse(model) ?: return + scopeAsync { + try { + request.surface = VfxModelSurface.of(HollowModelManager.loadModel(location)) + } catch (e: Exception) { + HollowEngine.LOGGER.warn("Could not read the spawn shape model {}: {}", model, e.message) + } + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxModuleRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxModuleRuntime.kt new file mode 100644 index 000000000..eeb5b39c0 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxModuleRuntime.kt @@ -0,0 +1,300 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.* +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxCollisionAction +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxCollisionSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceKind +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxModuleSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxNoiseSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvAnimationSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvMode +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxVelocityOverLifetimeSpec +import kotlin.math.floor +import kotlin.math.sqrt + +/** + * A behavior attached to a live emitter. + */ +interface VfxModule { + /** Once, as the particle appears. The emitter context is already filled for it. */ + fun onSpawn(emitter: VfxEmitter, slot: Int) = Unit + + /** Once per step, before any particle. */ + fun beginStep(emitter: VfxEmitter, dt: Float) = Unit + + /** Before the particle is integrated: forces and frames. */ + fun onStep(emitter: VfxEmitter, slot: Int, dt: Float) = Unit + + /** After the particle moved: collisions and anything that reacts to it. */ + fun onMoved(emitter: VfxEmitter, slot: Int, dt: Float) = Unit +} + +/** + * What a module is built from, besides its own spec. + */ +class VfxModuleContext( + val emitter: VfxEmitterSpec, + val expressions: VfxExpressions, + val drive: (field: String) -> VfxDrive?, +) { + fun scalar(field: String, value: VfxValue, default: Float = 0f): VfxFloatSampler = VfxSamplers.driven( + VfxSamplers.scalar(value, expressions, default, VfxRangeMode.PER_PARTICLE, field.hashCode()), + drive(field), + ) + + fun vector(field: String, value: VfxVec3Value, default: Float = 0f): VfxVec3Sampler = + VfxSamplers.vec3(value, expressions, default, VfxRangeMode.PER_PARTICLE, field.hashCode(), drive(field)) +} + +/** Turns one module spec into something that runs. Returning null leaves the module out. */ +fun interface VfxModuleFactory { + fun create(spec: VfxModuleSpec, context: VfxModuleContext): VfxModule? +} + +/** + * What each kind of module does at runtime, keyed by the same id the spec is registered under. + */ +object VfxModuleRuntimes { + val point = ExtensionPoints.create("hollowengine:vfx/module_runtimes".rl) + + init { + register("hollowengine:vfx/velocity_over_lifetime") { spec, context -> + VfxVelocityOverLifetimeModule(spec as VfxVelocityOverLifetimeSpec, context) + } + register("hollowengine:vfx/force") { spec, context -> + VfxForceModule(spec as VfxForceSpec, context) + } + register("hollowengine:vfx/noise") { spec, context -> + VfxNoiseModule(spec as VfxNoiseSpec, context) + } + register("hollowengine:vfx/collision") { spec, _ -> + VfxCollisionModule(spec as VfxCollisionSpec) + } + register("hollowengine:vfx/uv_animation") { spec, _ -> + val flipbook = spec as VfxUvAnimationSpec + if (flipbook.frameCount <= 1) null else VfxUvAnimationModule(flipbook) + } + } + + fun register(typeId: String, factory: VfxModuleFactory): ExtensionHandle = point.register(typeId.rl, factory) + + fun create(spec: VfxModuleSpec, context: VfxModuleContext): VfxModule? { + val type = VfxModuleTypes.of(spec) ?: return null + return point.find(type.key)?.create(spec, context) + } +} + +private class VfxVelocityOverLifetimeModule( + spec: VfxVelocityOverLifetimeSpec, + context: VfxModuleContext, +) : VfxModule { + private val velocity = context.vector("velocity", spec.velocity) + + /** Whether the authored frame and simulation frame are same one. */ + private val sameSpace = spec.space == context.emitter.space || context.emitter.space == VfxSimulationSpace.LOCAL + + private val scratch = MutableVec3f() + private val turned = MutableVec3f() + + override fun onStep(emitter: VfxEmitter, slot: Int, dt: Float) { + velocity.eval(emitter.context, scratch) + val offset = if (sameSpace) scratch else emitter.frame.transformDirection(scratch, turned) + emitter.stepVelocityX += offset.x + emitter.stepVelocityY += offset.y + emitter.stepVelocityZ += offset.z + } +} + +/** + * A force placed in emitter frame. + */ +private class VfxForceModule(private val spec: VfxForceSpec, context: VfxModuleContext) : VfxModule { + private val strength = context.scalar("strength", spec.strength) + private val radius = context.scalar("radius", spec.radius) + + private val center = MutableVec3f() + private val axis = MutableVec3f() + private var reach = 0f + private var hasAxis = false + + override fun beginStep(emitter: VfxEmitter, dt: Float) { + emitter.nodeToSimPoint(spec.center, center) + emitter.nodeToSimDirection(spec.direction, axis) + hasAxis = axis.sqrLength() > EPSILON + if (hasAxis) axis.norm() + reach = radius.eval(emitter.context) + } + + override fun onStep(emitter: VfxEmitter, slot: Int, dt: Float) { + val force = strength.eval(emitter.context) + if (force == 0f) return + + val particles = emitter.particles + val toX = particles.positionX[slot] - center.x + val toY = particles.positionY[slot] - center.y + val toZ = particles.positionZ[slot] - center.z + val distance = sqrt(toX * toX + toY * toY + toZ * toZ) + if (reach > 0f && distance > reach) return + + val fade = if (!spec.falloff || reach <= 0f) 1f else (1f - distance / reach).coerceIn(0f, 1f) + val push = force * fade * dt + + when (spec.kind) { + VfxForceKind.DIRECTIONAL -> { + if (!hasAxis) return + particles.velocityX[slot] += axis.x * push + particles.velocityY[slot] += axis.y * push + particles.velocityZ[slot] += axis.z * push + } + + VfxForceKind.POINT -> { + if (distance <= EPSILON) return + particles.velocityX[slot] += toX / distance * push + particles.velocityY[slot] += toY / distance * push + particles.velocityZ[slot] += toZ / distance * push + } + + VfxForceKind.VORTEX -> { + if (!hasAxis) return + val swirlX = axis.y * toZ - axis.z * toY + val swirlY = axis.z * toX - axis.x * toZ + val swirlZ = axis.x * toY - axis.y * toX + val length = sqrt(swirlX * swirlX + swirlY * swirlY + swirlZ * swirlZ) + if (length <= EPSILON) return + particles.velocityX[slot] += swirlX / length * push + particles.velocityY[slot] += swirlY / length * push + particles.velocityZ[slot] += swirlZ / length * push + } + + VfxForceKind.DRAG -> { + val keep = (1f - push).coerceIn(0f, 1f) + particles.velocityX[slot] *= keep + particles.velocityY[slot] *= keep + particles.velocityZ[slot] *= keep + } + } + } + + private companion object { + const val EPSILON = 1.0e-6f + } +} + +private class VfxNoiseModule(spec: VfxNoiseSpec, context: VfxModuleContext) : VfxModule { + private val strength = context.scalar("strength", spec.strength) + private val frequency = context.scalar("frequency", spec.frequency, 0.5f) + private val scrollSpeed = spec.scrollSpeed + private val octaves = spec.octaves.coerceIn(1, 4) + + override fun onStep(emitter: VfxEmitter, slot: Int, dt: Float) { + val force = strength.eval(emitter.context) + if (force == 0f) return + + val cells = frequency.eval(emitter.context) + val particles = emitter.particles + val x = particles.positionX[slot] * cells + val y = particles.positionY[slot] * cells + val z = particles.positionZ[slot] * cells + val scroll = emitter.instance.time * scrollSpeed + + val push = force * dt + particles.velocityX[slot] += VfxNoise.fractal(x + scroll, y, z, emitter.noiseSeed(0), octaves) * push + particles.velocityY[slot] += VfxNoise.fractal(x, y + scroll, z, emitter.noiseSeed(1), octaves) * push + particles.velocityZ[slot] += VfxNoise.fractal(x, y, z + scroll, emitter.noiseSeed(2), octaves) * push + } +} + +/** + * Stops particles at blocks. + */ +private class VfxCollisionModule(private val spec: VfxCollisionSpec) : VfxModule { + private val scratch = MutableVec3f() + + override fun onMoved(emitter: VfxEmitter, slot: Int, dt: Float) { + val particles = emitter.particles + + val x = particles.positionX[slot] + val y = particles.positionY[slot] + val z = particles.positionZ[slot] + if (!isSolidAt(emitter, x, y, z, scratch)) return + emitter.particleCollided(slot, dt) + + if (spec.action == VfxCollisionAction.DIE) { + particles.age[slot] = particles.lifetime[slot] + return + } + + var hit = false + if (isSolidAt(emitter, x, emitter.previousY, emitter.previousZ, scratch)) { + particles.positionX[slot] = emitter.previousX + particles.velocityX[slot] = bounce(particles.velocityX[slot]) + hit = true + } + if (isSolidAt(emitter, particles.positionX[slot], y, emitter.previousZ, scratch)) { + particles.positionY[slot] = emitter.previousY + particles.velocityY[slot] = bounce(particles.velocityY[slot]) + hit = true + } + if (isSolidAt(emitter, particles.positionX[slot], particles.positionY[slot], z, scratch)) { + particles.positionZ[slot] = emitter.previousZ + particles.velocityZ[slot] = bounce(particles.velocityZ[slot]) + hit = true + } + + if (!hit) { + particles.positionX[slot] = emitter.previousX + particles.positionY[slot] = emitter.previousY + particles.positionZ[slot] = emitter.previousZ + particles.velocityX[slot] = bounce(particles.velocityX[slot]) + particles.velocityY[slot] = bounce(particles.velocityY[slot]) + particles.velocityZ[slot] = bounce(particles.velocityZ[slot]) + } + + if (spec.friction < 1f) { + particles.velocityX[slot] *= spec.friction + particles.velocityY[slot] *= spec.friction + particles.velocityZ[slot] *= spec.friction + } + if (spec.lifetimeLoss > 0f) particles.age[slot] += spec.lifetimeLoss + } + + private fun bounce(velocity: Float): Float = + if (spec.action == VfxCollisionAction.BOUNCE) -velocity * spec.bounce else 0f + + private fun isSolidAt(emitter: VfxEmitter, x: Float, y: Float, z: Float, scratch: MutableVec3f): Boolean { + scratch.set(x, y, z) + emitter.simToRender.transform(scratch, 1f) + val origin = emitter.instance.origin + val radius = spec.radius + return emitter.instance.environment.isSolid( + origin.x + scratch.x, + origin.y + scratch.y - radius, + origin.z + scratch.z, + ) + } +} + +private class VfxUvAnimationModule(private val spec: VfxUvAnimationSpec) : VfxModule { + private val frames = spec.frameCount + + override fun onSpawn(emitter: VfxEmitter, slot: Int) { + if (spec.mode != VfxUvMode.RANDOM_FRAME) return + emitter.particles.frame[slot] = + floor(emitter.particles.random[slot] * frames).coerceIn(0f, (frames - 1).toFloat()) + } + + override fun onStep(emitter: VfxEmitter, slot: Int, dt: Float) { + val particles = emitter.particles + val index = when (spec.mode) { + VfxUvMode.RANDOM_FRAME -> return + VfxUvMode.OVER_LIFETIME -> emitter.context.progress * spec.cycles * frames + VfxUvMode.FPS -> particles.age[slot] * spec.fps + } + particles.frame[slot] = floor(index).mod(frames.toFloat()) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxNodeRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxNodeRuntime.kt new file mode 100644 index 000000000..668d1501c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxNodeRuntime.kt @@ -0,0 +1,171 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import org.joml.Matrix4f +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxBeamRenderer +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxCameraShake +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxDrawList +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxMeshNode +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxModelNode +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxPlaneNode +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxPostEffectNode +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxSkyNode +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxTrailRenderer +import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.* +import kotlin.math.sqrt + +/** + * One node of a playing effect: where it is, whether it is on, and whatever its kind does. + */ +class VfxNodeRuntime( + val spec: VfxNodeSpec, + val parent: VfxNodeRuntime?, + val instance: VfxInstance, + val expressions: VfxExpressions, + val seed: Int, + driven: Map, +) { + val frame = VfxFrame() + + /** What the author wrote for this node; the timeline starts from it every frame. */ + var authoredEnabled: Boolean = spec.enabled + var authoredTransform: VfxTransform = spec.transform + + var enabled: Boolean = spec.enabled + var transform: VfxTransform = spec.transform + + /** + * What the timeline writes into the values of this node. Only the properties that actually have + * a track get one. + */ + private val drives: Map = driven.mapValues { (_, channels) -> VfxDrive(channels) } + + fun drive(property: VfxProperty): VfxDrive? = drives[property] + + /** + * What a value read once for the whole node sees: the effect clock as its lifetime, the handle + * data and the distance to the camera. Particle inputs read as zero. + */ + val context = VfxEvalContext() + + /** The emitter this node draws the particles of, when it is a renderer directly under one. */ + val particleSource: VfxEmitter? = if (spec is VfxParticleRendererSpec) parent?.emitter else null + + val behavior: VfxNodeBehavior? = VfxNodeRuntimes.create(this) + + val emitter: VfxEmitter? get() = behavior as? VfxEmitter + + val children: List = spec.children.mapIndexed { index, child -> + VfxNodeRuntime( + child, + this, + instance, + expressions, + seed + index * 977 + 13, + instance.drivenProperties(child.id), + ) + } + + /** Whether this node and every node above it is on. */ + val isActive: Boolean get() = enabled && (parent?.isActive ?: true) + + fun place(root: VfxFrame) { + frame.setCombined(parent?.frame ?: root, transform) + behavior?.place() + children.forEach { it.place(root) } + } + + fun walk(): List = buildList { + add(this@VfxNodeRuntime) + children.forEach { addAll(it.walk()) } + } + + fun reset() { + enabled = authoredEnabled + transform = authoredTransform + children.forEach { it.reset() } + } + + /** Refreshes [context] for this frame. */ + fun fillContext() { + val camera = instance.camera + val dx = frame.position.x - camera.x + val dy = frame.position.y - camera.y + val dz = frame.position.z - camera.z + val duration = instance.effect.timeline.duration.coerceAtLeast(MIN_DURATION) + val local = instance.timelineTime + + context.age = local + context.lifetime = duration + context.progress = (local / duration).coerceIn(0f, 1f) + context.emitterAge = instance.time + context.effectTime = instance.time + context.partialTick = instance.partialTick + context.gameTime = instance.gameTime + context.data = instance.data + context.cameraDistance = sqrt(dx * dx + dy * dy + dz * dz) + } + + private companion object { + const val MIN_DURATION = 0.01f + } +} + +/** + * What a node of some kind does while its effect plays. + * + * Nodes are updated parents first, so a renderer under an emitter sees the particles of this step. + */ +interface VfxNodeBehavior { + /** The node frame has been placed for this frame. */ + fun place() = Unit + + fun update(dt: Float) = Unit + + /** Back to the first moment of the effect. */ + fun restart() = Unit + + /** The effect is stopping; [immediate] clears what is left at once. */ + fun stop(immediate: Boolean) = Unit + + /** Whether nothing of this node is left on screen. */ + val isIdle: Boolean get() = true + + /** Adds what the node draws; [placement] maps effect space to the space of the view. */ + fun collect(into: VfxDrawList, placement: Matrix4f) = Unit +} + +/** Makes the behavior of one node. Returning null leaves the node as a plain frame. */ +fun interface VfxNodeFactory { + fun create(node: VfxNodeRuntime): VfxNodeBehavior? +} + +/** + * What each kind of node does at runtime, keyed by the same id the spec is registered under. + */ +object VfxNodeRuntimes { + val point = ExtensionPoints.create("hollowengine:vfx/node_runtimes".rl) + + init { + register("hollowengine:vfx/emitter") { VfxEmitter(it.spec as VfxEmitterSpec, it) } + register("hollowengine:vfx/plane") { VfxPlaneNode(it.spec as VfxPlaneSpec, it) } + register("hollowengine:vfx/cube") { VfxMeshNode(it.spec as VfxMeshSpec, it) } + register("hollowengine:vfx/sphere") { VfxMeshNode(it.spec as VfxMeshSpec, it) } + register("hollowengine:vfx/cylinder") { VfxMeshNode(it.spec as VfxMeshSpec, it) } + register("hollowengine:vfx/model") { VfxModelNode(it.spec as VfxModelSpec, it) } + register("hollowengine:vfx/trail") { VfxTrailRenderer(it.spec as VfxTrailSpec, it) } + register("hollowengine:vfx/beam") { VfxBeamRenderer(it.spec as VfxBeamSpec, it) } + register("hollowengine:vfx/post_effect") { VfxPostEffectNode(it.spec as VfxPostEffectSpec, it) } + register("hollowengine:vfx/sky") { VfxSkyNode(it.spec as VfxSkySpec, it) } + register("hollowengine:vfx/camera_shake") { VfxCameraShake(it.spec as VfxCameraShakeSpec, it) } + } + + fun register(typeId: String, factory: VfxNodeFactory): ExtensionHandle = point.register(typeId.rl, factory) + + fun create(node: VfxNodeRuntime): VfxNodeBehavior? { + val type = VfxNodeTypes.of(node.spec) ?: return null + return point.find(type.key)?.create(node) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxParticleLook.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxParticleLook.kt new file mode 100644 index 000000000..86cb45af8 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxParticleLook.kt @@ -0,0 +1,94 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.common.utils.MutableColor +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxAppearance +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value + +/** + * The size, turn and color one renderer gives the particles of the emitter it sits under. + */ +class VfxParticleLook(spec: VfxAppearance, node: VfxNodeRuntime, particles: VfxParticles) { + /** [STRIDE] floats per particle: size, rotation in degrees, then color. */ + val data: FloatArray = particles.channel("look:${node.spec.id}", STRIDE) + + private val uniformSize = spec.uniformSize + private val size = vector(node, VfxProperty.SIZE, spec.size, 1f) + private val spin = vector(node, VfxProperty.SPIN, spec.rotation, 0f) + private val color = VfxSamplers.color( + spec.color, + node.expressions, + salt = VfxProperty.COLOR.hashCode(), + drive = node.drive(VfxProperty.COLOR), + ) + + private val liveSize = isLive(node, VfxProperty.SIZE, spec.size) + private val liveSpin = isLive(node, VfxProperty.SPIN, spec.rotation) + private val liveColor = !VfxSamplers.isFixedPerParticle(spec.color) || node.drive(VfxProperty.COLOR) != null + + private val scratchVector = MutableVec3f() + private val scratchColor = MutableColor(1f, 1f, 1f, 1f) + + /** Everything, for a particle that has just been born. */ + fun spawn(slot: Int, context: VfxEvalContext) { + readSize(slot, context) + readSpin(slot, context) + readColor(slot, context) + } + + /** What can change over the life of a particle. */ + fun step(slot: Int, context: VfxEvalContext) { + if (liveSize) readSize(slot, context) + if (liveSpin) readSpin(slot, context) + if (liveColor) readColor(slot, context) + } + + private fun readSize(slot: Int, context: VfxEvalContext) { + if (uniformSize) size.evalUniform(context, scratchVector) else size.eval(context, scratchVector) + val at = slot * STRIDE + SIZE + data[at] = scratchVector.x + data[at + 1] = scratchVector.y + data[at + 2] = scratchVector.z + } + + private fun readSpin(slot: Int, context: VfxEvalContext) { + spin.eval(context, scratchVector) + val at = slot * STRIDE + ROTATION + data[at] = scratchVector.x + data[at + 1] = scratchVector.y + data[at + 2] = scratchVector.z + } + + private fun readColor(slot: Int, context: VfxEvalContext) { + color.eval(context, scratchColor) + val at = slot * STRIDE + COLOR + data[at] = scratchColor.r + data[at + 1] = scratchColor.g + data[at + 2] = scratchColor.b + data[at + 3] = scratchColor.a + } + + companion object { + const val SIZE = 0 + const val ROTATION = 3 + const val COLOR = 6 + const val STRIDE = 10 + + /** The look of the one particle a renderer draws when it is not under an emitter. */ + fun unit(): FloatArray = floatArrayOf(1f, 1f, 1f, 0f, 0f, 0f, 1f, 1f, 1f, 1f) + + private fun vector(node: VfxNodeRuntime, property: VfxProperty, value: VfxVec3Value, default: Float) = + VfxSamplers.vec3( + value, + node.expressions, + default, + VfxRangeMode.PER_PARTICLE, + property.hashCode(), + node.drive(property), + ) + + private fun isLive(node: VfxNodeRuntime, property: VfxProperty, value: VfxVec3Value): Boolean = + !VfxSamplers.isFixedPerParticle(value) || node.drive(property) != null + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxParticles.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxParticles.kt new file mode 100644 index 000000000..4674af6cd --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxParticles.kt @@ -0,0 +1,88 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +/** + * The particles of one emitter, as flat arrays. + * + * Live particles are kept dense at the front: killing one moves the last into its slot, so the + * simulation is a plain index loop with no holes. + */ +class VfxParticles(val capacity: Int) { + var count: Int = 0 + private set + + val positionX = FloatArray(capacity) + val positionY = FloatArray(capacity) + val positionZ = FloatArray(capacity) + + val velocityX = FloatArray(capacity) + val velocityY = FloatArray(capacity) + val velocityZ = FloatArray(capacity) + + val age = FloatArray(capacity) + val lifetime = FloatArray(capacity) + + /** A number fixed at spawn, for variation that does not flicker from step to step. */ + val random = FloatArray(capacity) + + /** Spawn order within the emitter. */ + val index = FloatArray(capacity) + + /** The [random] of the particle a sub-emitter spawned this one from; 0 for any other. */ + val parentRandom = FloatArray(capacity) + + /** The sprite-sheet frame, as a whole number held in a float. */ + val frame = FloatArray(capacity) + + /** Packed block light of the particle position, refreshed while it moves. */ + val light = IntArray(capacity) + + private var extra: MutableMap? = null + + private class Channel(val stride: Int, val data: FloatArray) + + /** + * A private array of a module or a renderer (a renderer keeps the size and color it gives each + * particle in one, see [VfxParticleLook]), created on first use, [stride] floats per particle + * that move with it when it changes slots. + */ + fun channel(name: String, stride: Int = 1): FloatArray { + val channels = extra ?: LinkedHashMap().also { extra = it } + val channel = channels.getOrPut(name) { Channel(stride, FloatArray(capacity * stride)) } + check(channel.stride == stride) { "Channel $name is already ${channel.stride} floats wide" } + return channel.data + } + + /** Claims a slot, or returns -1 when the emitter is full. */ + fun allocate(): Int { + if (count >= capacity) return -1 + return count++ + } + + /** Kills the particle in [slot]; the last live particle takes its place. */ + fun kill(slot: Int) { + val last = count - 1 + if (slot != last) copy(last, slot) + count = last + } + + fun clear() { + count = 0 + } + + private fun copy(from: Int, to: Int) { + positionX[to] = positionX[from] + positionY[to] = positionY[from] + positionZ[to] = positionZ[from] + velocityX[to] = velocityX[from] + velocityY[to] = velocityY[from] + velocityZ[to] = velocityZ[from] + age[to] = age[from] + lifetime[to] = lifetime[from] + random[to] = random[from] + index[to] = index[from] + parentRandom[to] = parentRandom[from] + frame[to] = frame[from] + light[to] = light[from] + extra?.values?.forEach { System.arraycopy(it.data, from * it.stride, it.data, to * it.stride, it.stride) } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxSamplers.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxSamplers.kt new file mode 100644 index 000000000..669b799bf --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxSamplers.kt @@ -0,0 +1,200 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.common.utils.MutableColor +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxColorValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurveInput +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value + +/** + * An authored number, ready to read. + * + * Built once when an effect is compiled and then called per particle per step. + */ +fun interface VfxFloatSampler { + fun eval(context: VfxEvalContext): Float +} + +/** Three samplers that fill a vector without allocating one. */ +class VfxVec3Sampler( + private val x: VfxFloatSampler, + private val y: VfxFloatSampler, + private val z: VfxFloatSampler, +) { + fun eval(context: VfxEvalContext, into: MutableVec3f): MutableVec3f { + into.x = x.eval(context) + into.y = y.eval(context) + into.z = z.eval(context) + return into + } + + /** For properties whose three axes are driven by the X value alone. */ + fun evalUniform(context: VfxEvalContext, into: MutableVec3f): MutableVec3f { + val value = x.eval(context) + into.x = value + into.y = value + into.z = value + return into + } +} + +class VfxColorSampler(private val eval: (VfxEvalContext, MutableColor) -> Unit) { + fun eval(context: VfxEvalContext, into: MutableColor): MutableColor { + eval.invoke(context, into) + return into + } +} + +/** + * What a timeline track writes for one property of one node. + */ +class VfxDrive(channels: Int) { + val active = BooleanArray(channels) + val values = FloatArray(channels) +} + +/** + * How a range is read. + * + * A value read once per particle can simply draw a fresh number. A value read every step cannot, a + * size picked anew each step flickers. + */ +enum class VfxRangeMode { + FRESH, + PER_PARTICLE, +} + +object VfxSamplers { + fun scalar( + value: VfxValue, + expressions: VfxExpressions, + default: Float = 0f, + range: VfxRangeMode = VfxRangeMode.FRESH, + salt: Int = 0, + ): VfxFloatSampler = when (value) { + is VfxValue.Const -> { + val constant = value.value + VfxFloatSampler { constant } + } + + is VfxValue.Range -> { + val from = value.from + val span = value.to - value.from + when { + span == 0f -> VfxFloatSampler { from } + range == VfxRangeMode.PER_PARTICLE -> VfxFloatSampler { from + stableRandom(it.particleRandom, salt) * span } + else -> VfxFloatSampler { from + it.rng.nextFloat() * span } + } + } + + is VfxValue.OverTime -> { + val curve = value.curve + val scale = value.scale + val axis = axis(value.input) + VfxFloatSampler { curve.valueAt(axis.eval(it), default) * scale } + } + + is VfxValue.Expr -> { + val compiled = expressions.float(value.source, default) + VfxFloatSampler { compiled(it) } + } + } + + fun vec3( + value: VfxVec3Value, + expressions: VfxExpressions, + default: Float = 0f, + range: VfxRangeMode = VfxRangeMode.FRESH, + salt: Int = 0, + drive: VfxDrive? = null, + ) = VfxVec3Sampler( + driven(scalar(value.x, expressions, default, range, salt), drive, 0), + driven(scalar(value.y, expressions, default, range, salt + 1), drive, 1), + driven(scalar(value.z, expressions, default, range, salt + 2), drive, 2), + ) + + fun color( + value: VfxColorValue, + expressions: VfxExpressions, + salt: Int = 0, + drive: VfxDrive? = null, + ): VfxColorSampler { + val authored = authoredColor(value, expressions, salt) + if (drive == null) return authored + return VfxColorSampler { context, into -> + authored.eval(context, into) + if (drive.active[0]) into.r = drive.values[0] + if (drive.active[1]) into.g = drive.values[1] + if (drive.active[2]) into.b = drive.values[2] + if (drive.active[3]) into.a = drive.values[3] + } + } + + private fun authoredColor(value: VfxColorValue, expressions: VfxExpressions, salt: Int): VfxColorSampler = + when (value) { + is VfxColorValue.Solid -> { + val color = value.color + VfxColorSampler { _, into -> into.set(color.r, color.g, color.b, color.a) } + } + + is VfxColorValue.Gradient -> { + val gradient = value.gradient + val axis = axis(value.input) + VfxColorSampler { context, into -> + val stop = gradient.colorAt(axis.eval(context)) + into.set(stop.r, stop.g, stop.b, stop.a) + } + } + + is VfxColorValue.Channels -> { + val r = scalar(value.r, expressions, 1f, VfxRangeMode.PER_PARTICLE, salt) + val g = scalar(value.g, expressions, 1f, VfxRangeMode.PER_PARTICLE, salt + 1) + val b = scalar(value.b, expressions, 1f, VfxRangeMode.PER_PARTICLE, salt + 2) + val a = scalar(value.a, expressions, 1f, VfxRangeMode.PER_PARTICLE, salt + 3) + VfxColorSampler { context, into -> + into.set(r.eval(context), g.eval(context), b.eval(context), a.eval(context)) + } + } + } + + /** [sampler], unless the timeline has a curve for this channel. */ + fun driven(sampler: VfxFloatSampler, drive: VfxDrive?, channel: Int = 0): VfxFloatSampler { + if (drive == null || channel >= drive.values.size) return sampler + return VfxFloatSampler { if (drive.active[channel]) drive.values[channel] else sampler.eval(it) } + } + + /** What the horizontal axis of a curve or gradient reads. */ + fun axis(input: VfxCurveInput): VfxFloatSampler = when (input) { + VfxCurveInput.LIFETIME -> VfxFloatSampler { it.progress } + VfxCurveInput.PARTICLE_AGE -> VfxFloatSampler { it.age } + VfxCurveInput.EMITTER_AGE -> VfxFloatSampler { it.emitterAge } + VfxCurveInput.EFFECT_TIME -> VfxFloatSampler { it.effectTime } + VfxCurveInput.SPEED -> VfxFloatSampler { it.speed } + VfxCurveInput.RANDOM -> VfxFloatSampler { it.particleRandom } + VfxCurveInput.PARENT_RANDOM -> VfxFloatSampler { it.parentRandom } + } + + /** Whether a value reads same for a particle every step. */ + fun isFixedPerParticle(value: VfxValue): Boolean = value is VfxValue.Const || value is VfxValue.Range + + fun isFixedPerParticle(value: VfxVec3Value): Boolean = + isFixedPerParticle(value.x) && isFixedPerParticle(value.y) && isFixedPerParticle(value.z) + + fun isFixedPerParticle(value: VfxColorValue): Boolean = when (value) { + is VfxColorValue.Solid -> true + is VfxColorValue.Gradient -> false + is VfxColorValue.Channels -> + isFixedPerParticle(value.r) && isFixedPerParticle(value.g) && + isFixedPerParticle(value.b) && isFixedPerParticle(value.a) + } + + /** A number in 0..1 that depends only on the particle seed and [salt]. */ + fun stableRandom(seed: Float, salt: Int): Float { + var hash = seed.toRawBits() xor (salt * -0x61c88647) + hash = (hash xor (hash ushr 16)) * -0x7a143595 + hash = (hash xor (hash ushr 13)) * -0x3d4d51cb + hash = hash xor (hash ushr 16) + return (hash ushr 8) * (1f / (1 shl 24)) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxScenes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxScenes.kt new file mode 100644 index 000000000..39ea33986 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxScenes.kt @@ -0,0 +1,172 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import net.minecraft.client.Minecraft +import net.minecraft.client.multiplayer.ClientLevel +import net.minecraft.nbt.CompoundTag +import net.minecraft.world.entity.Entity +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.client.handlers.TickHandler +import ru.hollowhorizon.hollowengine.common.data.NbtDataStore +import ru.hollowhorizon.hollowengine.common.vfx.VfxPlayPacket + +/** + * A playing effect and whatever it is following. + */ +class VfxPlayback( + val handle: Int, + val instance: VfxInstance, + private val entity: Entity?, +) { + private val data = NbtDataStore() + /** Where it was last frame; NaN until the first one, which has no motion to speak of. */ + private var lastX = Double.NaN + private var lastY = Double.NaN + private var lastZ = Double.NaN + + var position: Vec3 = Vec3.ZERO + private set + + val isFinished: Boolean + get() = instance.isFinished || (entity != null && !entity.isAlive) + + fun moveTo(next: Vec3) { + position = next + } + + fun applyData(tag: CompoundTag) { + data.load(tag) + instance.readData(data) + } + + fun stop(immediate: Boolean) = instance.stop(immediate) + + /** Follows whatever it is bound to, then advances the simulation. */ + fun update(dt: Float, partialTick: Float, gameTime: Float, camera: Vec3) { + val next = entity?.getPosition(partialTick)?.add(0.0, entity.bbHeight * 0.5, 0.0) ?: position + if (dt > 0f && !lastX.isNaN()) { + instance.setVelocity( + ((next.x - lastX) / dt).toFloat(), + ((next.y - lastY) / dt).toFloat(), + ((next.z - lastZ) / dt).toFloat(), + ) + } + lastX = next.x + lastY = next.y + lastZ = next.z + + position = next + instance.moveTo(next) + instance.setCamera(camera) + instance.partialTick = partialTick + instance.gameTime = gameTime + instance.update(dt) + } +} + +/** + * The effects playing in one level. + */ +class VfxScene(private val environment: VfxEnvironment) { + private val playbacks = ArrayList() + private var lastGameTime = Float.NaN + + val isEmpty: Boolean get() = playbacks.isEmpty() + + fun play(handle: Int, asset: String, position: Vec3, entity: Entity?, data: CompoundTag): VfxPlayback? { + val effect = VfxAssets[asset] ?: return null + + val instance = VfxInstance(effect, asset, environment) + instance.moveTo(position, resetAnchor = true) + + val playback = VfxPlayback(handle, instance, entity) + playback.moveTo(position) + if (!data.isEmpty()) playback.applyData(data) + + playbacks.removeAll { it.handle == handle } + playbacks += playback + return playback + } + + fun of(handle: Int): VfxPlayback? = playbacks.firstOrNull { it.handle == handle } + + fun clear() = playbacks.clear() + + /** Advances everything with the time the game is keeping, seen from [camera]. */ + fun update(camera: Vec3) { + val now = TickHandler.gameTime + val dt = when { + lastGameTime.isNaN() -> 0f + else -> ((now - lastGameTime) / TICKS_PER_SECOND).coerceIn(0f, MAX_STEP_SECONDS) + } + lastGameTime = now + + val budget = VfxBudget.share(playbacks.size) + val partialTick = TickHandler.partialTick + playbacks.forEach { playback -> + playback.instance.budget = budget + playback.update(dt, partialTick, now, camera) + } + playbacks.removeAll { it.isFinished } + } + + fun forEachInstance(action: (VfxInstance, Vec3) -> Unit) { + playbacks.forEach { action(it.instance, it.position) } + } + + private companion object { + const val TICKS_PER_SECOND = 20f + + /** After a freeze the clock can jump; a particle should not travel half a second in one step. */ + const val MAX_STEP_SECONDS = 0.25f + } +} + +/** + * How many particles all the effects on screen may have between them. + */ +object VfxBudget { + var limit: Int = 8000 + + fun share(consumers: Int): Int = if (consumers <= 0) limit else (limit / consumers).coerceAtLeast(16) +} + +/** + * The effects of the client, one scene per level. + */ +object VfxScenes { + private var level: ClientLevel? = null + private var scene: VfxScene? = null + + /** The scene of the level the player is in, created on first use. */ + fun current(): VfxScene? { + val current = Minecraft.getInstance().level ?: return null + if (level !== current) { + level = current + scene = VfxScene(VfxWorldEnvironment(current)) + } + return scene + } + + fun play(packet: VfxPlayPacket) { + val scene = current() ?: return + val position = packet.position ?: packet.entity?.let { Vec3(it.x, it.y, it.z) } ?: Vec3.ZERO + scene.play(packet.handle, packet.effect, position, packet.entity, packet.data) + } + + fun stop(handle: Int, immediate: Boolean) { + scene?.of(handle)?.stop(immediate) + } + + fun move(handle: Int, position: Vec3) { + scene?.of(handle)?.moveTo(position) + } + + fun applyData(handle: Int, data: CompoundTag) { + scene?.of(handle)?.applyData(data) + } + + /** A pack reload replaces every effect definition, so whatever is playing is no longer valid. */ + fun onAssetsReloaded() { + scene?.clear() + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxSubEmitters.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxSubEmitters.kt new file mode 100644 index 000000000..195de73cc --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/VfxSubEmitters.kt @@ -0,0 +1,114 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxParticleEvent +import ru.hollowhorizon.hollowengine.common.vfx.VfxSubEmission + +/** + * Events a sub-emitter has been handed and not yet spawned from, already in the space it simulates + * in: where, with how much velocity to add, the random number of the particle that had the event, + * and how many particles to spawn. + */ +internal class VfxSubEvents { + var size: Int = 0 + private set + + private var data = FloatArray(STRIDE * 16) + + fun add(x: Float, y: Float, z: Float, vx: Float, vy: Float, vz: Float, random: Float, count: Int) { + if ((size + 1) * STRIDE > data.size) data = data.copyOf(data.size * 2) + val at = size * STRIDE + data[at] = x + data[at + 1] = y + data[at + 2] = z + data[at + 3] = vx + data[at + 4] = vy + data[at + 5] = vz + data[at + 6] = random + data[at + 7] = count.toFloat() + size++ + } + + fun x(event: Int) = data[event * STRIDE] + fun y(event: Int) = data[event * STRIDE + 1] + fun z(event: Int) = data[event * STRIDE + 2] + fun velocityX(event: Int) = data[event * STRIDE + 3] + fun velocityY(event: Int) = data[event * STRIDE + 4] + fun velocityZ(event: Int) = data[event * STRIDE + 5] + fun random(event: Int) = data[event * STRIDE + 6] + fun count(event: Int) = data[event * STRIDE + 7].toInt() + + fun clear() { + size = 0 + } + + private companion object { + const val STRIDE = 8 + } +} + +/** + * What makes an emitter a sub-emitter: the emitter it listens to, and the events it was handed. + */ +internal class VfxSubEmitterInput( + private val spec: VfxSubEmission, + val source: VfxEmitter, + node: VfxNodeRuntime, +) { + val events = VfxSubEvents() + + private val count = VfxSamplers.scalar(spec.count, node.expressions, 0f, VfxRangeMode.PER_PARTICLE, COUNT_SALT) + private val inheritVelocity = + VfxSamplers.scalar(spec.inheritVelocity, node.expressions, 0f, VfxRangeMode.PER_PARTICLE, VELOCITY_SALT) + + /** How many particles an [VfxParticleEvent.ALIVE] sub-emitter still owes each particle of [source]. */ + private val debtChannel = "sub:${node.spec.id}" + + /** The space [source] simulates in, to the one this emitter does; refreshed as the frames are placed. */ + private val sourceToOwn = MutableMat4f() + private val renderToOwn = MutableMat4f() + private val point = MutableVec3f() + private val velocity = MutableVec3f() + + fun place(ownSimToRender: MutableMat4f) { + ownSimToRender.invert(renderToOwn) + renderToOwn.mul(source.simToRender, sourceToOwn) + } + + /** A particle of [source] in [slot] had [event]; its context is filled for it. */ + fun take(event: VfxParticleEvent, slot: Int, dt: Float) { + val particles = source.particles + if (spec.event == VfxParticleEvent.ALIVE && event == VfxParticleEvent.SPAWN) { + particles.channel(debtChannel)[slot] = 0f + } + if (event != spec.event) return + + val context = source.context + val spawned = if (event == VfxParticleEvent.ALIVE) { + val debt = particles.channel(debtChannel) + debt[slot] += count.eval(context).coerceAtLeast(0f) * dt + debt[slot].toInt().also { debt[slot] -= it } + } else { + count.eval(context).coerceAtLeast(0f).toInt() + } + if (spawned <= 0) return + + point.set(particles.positionX[slot], particles.positionY[slot], particles.positionZ[slot]) + sourceToOwn.transform(point, 1f) + val inherit = inheritVelocity.eval(context) + velocity.set( + particles.velocityX[slot] * inherit, + particles.velocityY[slot] * inherit, + particles.velocityZ[slot] * inherit, + ) + sourceToOwn.transform(velocity, 0f) + + events.add(point.x, point.y, point.z, velocity.x, velocity.y, velocity.z, particles.random[slot], spawned) + } + + private companion object { + const val COUNT_SALT = 0x5b1 + const val VELOCITY_SALT = 0x5b2 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxDrawList.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxDrawList.kt new file mode 100644 index 000000000..c315c7bd9 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxDrawList.kt @@ -0,0 +1,147 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import org.joml.Matrix4f +import org.joml.Vector3f +import ru.hollowhorizon.hollowengine.client.models.internal.v2.ModelAttachment +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticleLook +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticles +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxMaterialSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxMeshSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxModelSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPlaneSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPrimitive + +/** + * Where the particles are looked at from. + * + * Everything a draw places is expressed in one space, the one [modelView] starts from: + * camera-relative world space in the world, effect space in the editor preview. The camera axes and + * position are given in that same space, which is all a billboard or a ribbon needs to face the + * viewer. + */ +class VfxView( + val modelView: Matrix4f, + val projection: Matrix4f, + val right: Vector3f, + val up: Vector3f, + val eye: Vector3f, +) { + companion object +} + +/** + * The particles a renderer draws, and how they sit relative to it. + * + * Under an emitter, [offset], [spin] and [sizeScale] are the renderer node transform, applied to + * each particle. A renderer on its own draws one particle of its own at the origin of [matrix], + * which already carries its node frame. + */ +class VfxParticleBatch( + val particles: VfxParticles, + val look: FloatArray, + /** Simulation space to the space of the view the batch is drawn with. */ + val matrix: Matrix4f, + val uvColumns: Int = 1, + val uvRows: Int = 1, + val offset: Vec3f = Vec3f.ZERO, + val spin: Vec3f = Vec3f.ZERO, + val sizeScale: Vec3f = Vec3f.ONES, + /** Multiplied into every particle color. */ + val tint: FloatArray = floatArrayOf(1f, 1f, 1f, 1f), +) { + fun lookOf(slot: Int): Int = slot * VfxParticleLook.STRIDE +} + +/** One plane renderer's worth of quads. */ +class VfxQuadDraw( + val plane: VfxPlaneSpec, + val batch: VfxParticleBatch, + val uniforms: VfxUniformValues?, +) + +/** One mesh renderer's worth of instances of a built-in mesh. */ +class VfxMeshDraw( + val spec: VfxMeshSpec, + val primitive: VfxPrimitive, + val batch: VfxParticleBatch, + val uniforms: VfxUniformValues?, +) + +/** One model renderer's worth of model instances. */ +class VfxModelDraw( + val spec: VfxModelSpec, + val model: ModelAttachment, + val batch: VfxParticleBatch, +) + +/** + * Ribbons of one trail or beam node: strips of points, already in the space of the view. + * + * Each point is [STRIDE] floats: position, half width, color, the texture coordinate along the + * ribbon, and the packed light. + */ +class VfxRibbonDraw( + val material: VfxMaterialSpec, + val uniforms: VfxUniformValues?, + val points: FloatArray, + /** Start point and point count of every strip, in pairs. */ + val strips: IntArray, + val stripCount: Int, + /** Whether the texture coordinate along the ribbon runs past 1, so the texture has to repeat. */ + val repeat: Boolean, +) { + companion object { + const val STRIDE = 10 + } +} + +/** A full-screen pass, with the uniforms it is drawn with this frame. */ +class VfxPostDraw(val shader: String, val uniforms: VfxUniformValues) + +/** A sky node's pass, with where its node is in the space of the view and how long the effect has played. */ +class VfxSkyDraw(val shader: String, val uniforms: VfxUniformValues, val position: Vector3f, val time: Float) + +/** + * Everything the playing effects draw this frame, sorted by how it is drawn. + */ +class VfxDrawList { + val quads = ArrayList() + val meshes = ArrayList() + val models = ArrayList() + val ribbons = ArrayList() + val posts = ArrayList() + val skies = ArrayList() + + /** Camera shake in degrees: pitch, yaw and roll, summed over every shaking node. */ + val shake = FloatArray(3) + + val isEmpty: Boolean + get() = quads.isEmpty() && meshes.isEmpty() && models.isEmpty() && ribbons.isEmpty() && posts.isEmpty() && + skies.isEmpty() + + /** + * Whether anything reads the scene textures: a surface with a shader of the author, which may, + * or one with softness, which reads the depth. + */ + val readsScene: Boolean + get() = materials().any { it.shader != null || it.softness > 0f } + + /** Whether any surface glows, which is what the glow pass is drawn for. */ + val glows: Boolean + get() = materials().any { it.effectiveGlow() > 0f } + + private fun materials(): Sequence = + quads.asSequence().map { it.plane.material } + meshes.asSequence().map { it.spec.material } + + ribbons.asSequence().map { it.material } + + fun clear() { + quads.clear() + meshes.clear() + models.clear() + ribbons.clear() + posts.clear() + skies.clear() + shake.fill(0f) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxDraws.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxDraws.kt new file mode 100644 index 000000000..4605754b2 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxDraws.kt @@ -0,0 +1,125 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.pipeline.RenderTarget +import com.mojang.blaze3d.vertex.PoseStack +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.LightTexture +import net.minecraft.client.renderer.texture.OverlayTexture +import net.minecraft.util.Mth +import org.joml.Matrix4f +import org.joml.Quaternionf +import org.joml.Vector3f +import ru.hollowhorizon.hollowengine.client.models.internal.rendering.InstanceBatchManager +import ru.hollowhorizon.hollowengine.client.models.internal.rendering.RenderContext +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticleLook + +/** + * Draws one frame worth of effects, everything but the full-screen passes, which come once the + * level is done. + */ +object VfxFrameRenderer { + /** + * Draws [list] as seen from [view] into [target], which is bound. Models go through the model + * renderer and the buffers of the game, so they take their matrices from the render system, + * which has to hold the same view. + */ + fun render(list: VfxDrawList, view: VfxView, target: RenderTarget) { + renderModels(list.models) + renderSurfaces(list, view, target) + } + + /** + * Everything drawn with the effect shaders: the sky, then meshes, ribbons and planes, then the glow + * of whatever glows, over all of them. + */ + fun renderSurfaces(list: VfxDrawList, view: VfxView, target: RenderTarget) { + if (list.readsScene) VfxSceneTextures.capture(target) + + VfxSkyRenderer.render(list.skies, view) + VfxMeshRenderer.render(list.meshes, view) + VfxRibbonRenderer.render(list.ribbons, view) + VfxQuadRenderer.render(list.quads, view) + + if (list.glows && VfxGlow.begin(target)) { + VfxMeshRenderer.renderGlow(view) + VfxRibbonRenderer.render(list.ribbons, view, glow = true) + VfxQuadRenderer.renderGlow(view) + VfxGlow.finish(target) + } + } + + fun renderModels(draws: List) { + if (draws.isEmpty()) return + val source = Minecraft.getInstance().renderBuffers().bufferSource() + val stack = PoseStack() + + draws.forEach { draw -> + val attachment = draw.model + attachment.ensureReady() + attachment.beginPose() + attachment.endPose() + + val batch = draw.batch + val particles = batch.particles + for (slot in 0 until particles.count) { + stack.pushPose() + stack.mulPose(batch.matrix) + stack.translate( + particles.positionX[slot] + batch.offset.x, + particles.positionY[slot] + batch.offset.y, + particles.positionZ[slot] + batch.offset.z, + ) + if (draw.spec.alignToVelocity) { + stack.mulPose( + VfxBillboards.facing(particles.velocityX[slot], particles.velocityY[slot], particles.velocityZ[slot]) + ) + } + val look = batch.look + val spin = batch.lookOf(slot) + VfxParticleLook.ROTATION + val size = batch.lookOf(slot) + VfxParticleLook.SIZE + stack.mulPose( + Quaternionf().rotateZYX( + (look[spin + 2] + batch.spin.z) * Mth.DEG_TO_RAD, + (look[spin + 1] + batch.spin.y) * Mth.DEG_TO_RAD, + (look[spin] + batch.spin.x) * Mth.DEG_TO_RAD, + ) + ) + stack.scale( + look[size] * batch.sizeScale.x, + look[size + 1] * batch.sizeScale.y, + look[size + 2] * batch.sizeScale.z, + ) + + attachment.pipeline.render( + RenderContext( + stack = stack, + source = source, + light = if (draw.spec.emissive) LightTexture.FULL_BRIGHT else particles.light[slot], + overlay = OverlayTexture.NO_OVERLAY, + allowInstancing = true, + ) + ) + stack.popPose() + } + } + + source.endBatch() + InstanceBatchManager.flush() + InstanceBatchManager.clear() + } +} + +/** + * The view of the game camera, whose space is camera-relative world space. + */ +fun VfxView.Companion.ofCamera(modelView: Matrix4f, projection: Matrix4f): VfxView { + val screenToWorld = Matrix4f(projection).mul(modelView).invert() + val center = screenToWorld.transformProject(Vector3f(0f, 0f, 0.5f)) + return VfxView( + modelView = Matrix4f(modelView), + projection = Matrix4f(projection), + right = screenToWorld.transformProject(Vector3f(1f, 0f, 0.5f)).sub(center).normalize(), + up = screenToWorld.transformProject(Vector3f(0f, 1f, 0.5f)).sub(center).normalize(), + eye = Vector3f(), + ) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGlow.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGlow.kt new file mode 100644 index 000000000..939d8ade5 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGlow.kt @@ -0,0 +1,191 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.pipeline.RenderTarget +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.DefaultVertexFormat +import com.mojang.blaze3d.vertex.Tesselator +import com.mojang.blaze3d.vertex.VertexFormat +import net.minecraft.client.renderer.ShaderInstance +import org.joml.Matrix4f +import org.joml.Vector3f +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.HollowEngine + +/** + * The glow of the frame: surfaces with a glow draw a second time into a float texture that shares the + * depth of the frame, so the scene hides it where it hides them, and that texture is blurred down a + * pyramid of halving levels and added over the frame. + */ +object VfxGlow { + private const val DOWNSAMPLE = "hollowengine:vfx/glow/downsample" + private const val UPSAMPLE = "hollowengine:vfx/glow/upsample" + + private const val MAX_LEVELS = 6 + private const val MIN_LEVEL_SIZE = 8 + + /** How much of the coarser level each finer one takes on the way up; more spreads the glow wider. */ + private const val SCATTER = 0.7f + + /** The world and the editor preview draw into targets of different sizes in one frame; both stay. */ + private const val KEPT_CHAINS = 3 + + private val chains = object : LinkedHashMap(4, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = + (size > KEPT_CHAINS).also { if (it) eldest.value.delete() } + } + + private var current: GlowChain? = null + private var warned = false + + /** + * Binds the glow surface for [frame], cleared and over its depth. Returns false when that cannot + * be done, and [frame] is bound again. + */ + fun begin(frame: RenderTarget): Boolean { + val chain = chains.getOrPut(frame.width.toLong() shl 32 or frame.height.toLong()) { + GlowChain(frame.width, frame.height) + } + chain.base.bind() + GL33.glFramebufferTexture2D( + GL33.GL_FRAMEBUFFER, GL33.GL_DEPTH_ATTACHMENT, GL33.GL_TEXTURE_2D, frame.depthTextureId, 0, + ) + if (GL33.glCheckFramebufferStatus(GL33.GL_FRAMEBUFFER) != GL33.GL_FRAMEBUFFER_COMPLETE) { + if (!warned) HollowEngine.LOGGER.warn("Effect glow is off: the frame depth cannot be shared") + warned = true + detachDepth() + frame.bindWrite(true) + return false + } + GL33.glClearBufferfv(GL33.GL_COLOR, 0, floatArrayOf(0f, 0f, 0f, 0f)) + current = chain + return true + } + + /** Blurs what was drawn since [begin] and adds it over [frame], which is bound afterward. */ + fun finish(frame: RenderTarget) { + val chain = current ?: return + current = null + chain.base.bind() + detachDepth() + + val down = VfxShaders.get(DOWNSAMPLE, DefaultVertexFormat.POSITION_TEX) + val up = VfxShaders.get(UPSAMPLE, DefaultVertexFormat.POSITION_TEX) + if (down == null || up == null) { + frame.bindWrite(true) + return + } + + RenderSystem.disableDepthTest() + RenderSystem.depthMask(false) + RenderSystem.disableCull() + try { + RenderSystem.disableBlend() + var source = chain.base + chain.levels.forEachIndexed { index, level -> + level.bind() + VfxScreenQuad.draw(down) { shader -> + shader.setSampler("Source", source.texture) + shader.safeGetUniform("SourceTexel").set(1f / source.width, 1f / source.height) + shader.safeGetUniform("FirstPass").set(if (index == 0) 1f else 0f) + } + source = level + } + + RenderSystem.enableBlend() + GL33.glBlendColor(0f, 0f, 0f, SCATTER) + RenderSystem.blendFunc( + GlStateManager.SourceFactor.CONSTANT_ALPHA, GlStateManager.DestFactor.ONE_MINUS_CONSTANT_ALPHA, + ) + for (index in chain.levels.lastIndex downTo 1) { + chain.levels[index - 1].bind() + upsample(up, chain.levels[index]) + } + GL33.glBlendColor(0f, 0f, 0f, 0f) + + frame.bindWrite(true) + RenderSystem.blendFunc(GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ONE) + upsample(up, chain.levels.firstOrNull() ?: chain.base) + } finally { + VfxMaterialStates.restore() + } + } + + private fun upsample(shader: ShaderInstance, source: GlowSurface) { + VfxScreenQuad.draw(shader) { bound -> + bound.setSampler("Source", source.texture) + bound.safeGetUniform("SourceTexel").set(1f / source.width, 1f / source.height) + bound.safeGetUniform("Strength").set(1f) + } + } + + /** The depth belongs to the frame, which may be resized or deleted while this is not looking. */ + private fun detachDepth() { + GL33.glFramebufferTexture2D(GL33.GL_FRAMEBUFFER, GL33.GL_DEPTH_ATTACHMENT, GL33.GL_TEXTURE_2D, 0, 0) + } + + /** The full-size surface and the halving levels under it, for one size of frame. */ + private class GlowChain(width: Int, height: Int) { + val base = GlowSurface(width, height) + val levels: List = buildList { + var levelWidth = width / 2 + var levelHeight = height / 2 + while (size < MAX_LEVELS && levelWidth >= MIN_LEVEL_SIZE && levelHeight >= MIN_LEVEL_SIZE) { + add(GlowSurface(levelWidth, levelHeight)) + levelWidth /= 2 + levelHeight /= 2 + } + } + + fun delete() { + base.delete() + levels.forEach(GlowSurface::delete) + } + } + + /** A half-float color texture and the framebuffer that draws into it. */ + private class GlowSurface(val width: Int, val height: Int) { + val texture: Int = GlStateManager._genTexture() + private val framebuffer: Int = GlStateManager.glGenFramebuffers() + + init { + GlStateManager._bindTexture(texture) + GlStateManager._texImage2D( + GL33.GL_TEXTURE_2D, 0, GL33.GL_RGBA16F, width, height, 0, GL33.GL_RGBA, GL33.GL_FLOAT, null, + ) + GlStateManager._texParameter(GL33.GL_TEXTURE_2D, GL33.GL_TEXTURE_MIN_FILTER, GL33.GL_LINEAR) + GlStateManager._texParameter(GL33.GL_TEXTURE_2D, GL33.GL_TEXTURE_MAG_FILTER, GL33.GL_LINEAR) + GlStateManager._texParameter(GL33.GL_TEXTURE_2D, GL33.GL_TEXTURE_WRAP_S, GL33.GL_CLAMP_TO_EDGE) + GlStateManager._texParameter(GL33.GL_TEXTURE_2D, GL33.GL_TEXTURE_WRAP_T, GL33.GL_CLAMP_TO_EDGE) + GlStateManager._glBindFramebuffer(GL33.GL_FRAMEBUFFER, framebuffer) + GlStateManager._glFramebufferTexture2D( + GL33.GL_FRAMEBUFFER, GL33.GL_COLOR_ATTACHMENT0, GL33.GL_TEXTURE_2D, texture, 0, + ) + } + + fun bind() { + GlStateManager._glBindFramebuffer(GL33.GL_FRAMEBUFFER, framebuffer) + RenderSystem.viewport(0, 0, width, height) + } + + fun delete() { + GlStateManager._deleteTexture(texture) + GlStateManager._glDeleteFramebuffers(framebuffer) + } + } +} + +/** A quad over the whole target, for the passes that work on a picture rather than on geometry. */ +internal object VfxScreenQuad { + private val SCREEN = VfxView(Matrix4f(), Matrix4f(), Vector3f(1f, 0f, 0f), Vector3f(0f, 1f, 0f), Vector3f()) + + fun draw(shader: ShaderInstance, prepare: (ShaderInstance) -> Unit) { + val builder = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX) + builder.addVertex(-1f, -1f, 0f).setUv(0f, 0f) + builder.addVertex(1f, -1f, 0f).setUv(1f, 0f) + builder.addVertex(1f, 1f, 0f).setUv(1f, 1f) + builder.addVertex(-1f, 1f, 0f).setUv(0f, 1f) + val mesh = builder.build() ?: return + SCREEN.drawImmediate(mesh, shader, prepare) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGraphMaterials.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGraphMaterials.kt new file mode 100644 index 000000000..81b835cd4 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGraphMaterials.kt @@ -0,0 +1,161 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.vertex.DefaultVertexFormat +import com.mojang.blaze3d.vertex.VertexFormat +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.shadergraph.* +import java.util.* +import java.util.concurrent.atomic.AtomicInteger + +/** + * Kinds of surface an effect draws, each with the vertex layout its programs read and the + * template a material graph is put into for it. + */ +enum class VfxSurface(val format: VertexFormat, internal val template: ShaderGraphTemplate) { + /** Billboards and other planes, one instanced quad per particle. */ + PLANE(DefaultVertexFormat.POSITION_TEX, ShaderGraphTemplates.PARTICLE), + + /** The built-in meshes, instanced. */ + MESH(DefaultVertexFormat.POSITION_TEX, ShaderGraphTemplates.MESH), + + /** Trails and beams, built every frame in the vanilla particle format. */ + RIBBON(DefaultVertexFormat.PARTICLE, ShaderGraphTemplates.RIBBON), +} + +/** + * Materials made in the shader graph editor, as effects use them: a material names one by its + * location, `namespace:materials/fire.material`, where it would name a core shader. + */ +object VfxGraphMaterials { + private class Loaded(val graph: ShaderGraph?, val code: ShaderGraphCode?) { + val programs = EnumMap(VfxSurface::class.java) + } + + private val loaded = HashMap() + + /** Programs are cached by the names of their stages, so every build gets names of its own. */ + private val builds = AtomicInteger() + + fun isGraph(location: String?): Boolean = location?.endsWith(ShaderGraph.EXTENSION) == true + + /** The graph at [location] as the packs have it now, or null when there is none or it does not read. */ + fun read(location: String): ShaderGraph? { + val id = ResourceLocation.tryParse(location) ?: return null + val resource = Minecraft.getInstance().resourceManager.getResource(id).orElse(null) ?: return null + return runCatching { + resource.openAsReader().use { ShaderGraphFormat.read(it.readText()) } + }.onFailure { HollowEngine.LOGGER.warn("Could not read the material {}: {}", location, it.message) }.getOrNull() + } + + /** What an effect gives values to: a uniform per number or vector property, a sampler per texture. */ + fun declaration(graph: ShaderGraph): VfxShaderDeclaration { + val textures = graph.properties.filter { it.type == ShaderType.TEXTURE } + val uniforms = graph.properties.filter { it.type.isVector }.map { property -> + val defaults = FloatArray(property.type.width) { + property.default.getOrElse(it) { + property.default.lastOrNull() ?: 0f + } + } + VfxShaderUniform(property.name, "float", property.type.width, defaults) + } + return VfxShaderDeclaration( + uniforms = uniforms, + samplers = textures.map { it.name }, + drawsGlow = true, + glslNames = graph.properties.associate { it.name to propertyUniform(it.name) }, + samplerDefaults = textures.filter { it.texture.isNotBlank() }.associate { it.name to it.texture }, + ) + } + + /** The program [surface] draws the material at [location] with, or null when it cannot be built. */ + fun program(location: String, surface: VfxSurface): ShaderInstance? { + val entry = loaded(location) + return entry.programs.getOrPut(surface) { + val graph = entry.graph ?: return@getOrPut null + val code = entry.code ?: return@getOrPut null + build(location, graph, code, surface) + } + } + + /** Whether the material links anything into its emission, which glows even with glow of effect left at 0. */ + fun emits(location: String?): Boolean { + if (location == null || !isGraph(location)) return false + return loaded(location).code?.linkedOutputs?.contains(SurfaceOutputs.EMISSION) == true + } + + /** Material at [location] was saved: it is read and built again next time it is drawn. */ + fun changed(location: String) { + loaded.remove(location)?.programs?.values?.forEach { it?.close() } + VfxShaderDeclarations.invalidate(location) + VfxQuadRenderer.invalidate() + VfxMeshRenderer.invalidate() + } + + /** Where an IDE file of a material, `assets//`, is found in the packs. */ + fun locationOf(path: String): String = path.substringAfter("assets/").replaceFirst("/", ":") + + fun clear() { + loaded.values.forEach { entry -> entry.programs.values.forEach { it?.close() } } + loaded.clear() + } + + private fun loaded(location: String): Loaded = loaded.getOrPut(location) { + val graph = read(location) + Loaded(graph, graph?.let(ShaderGraphCompiler::compile)) + } + + private fun build( + location: String, + graph: ShaderGraph, + code: ShaderGraphCode, + surface: VfxSurface, + ): ShaderInstance? { + val sources = ShaderGraphTemplates.surface(code, surface.template) + val name = + "vfx_${location.lowercase().replace(Unsafe, "_")}_${surface.name.lowercase()}_${builds.incrementAndGet()}" + val stage = ShaderGraphPrograms.stage(name) + val json = ShaderGraphPrograms.json( + vertex = stage, + fragment = stage, + attributes = surface.format.elementAttributeNames, + samplers = listOf( + "Sampler0", "Sampler2", "SceneDepth", "SceneColor" + ) + ShaderGraphPrograms.propertySamplers(graph.properties), + uniforms = uniforms(surface) + ShaderGraphPrograms.propertyUniforms(graph.properties), + ) + return ShaderGraphPrograms.create( + name, json, mapOf("vsh" to sources.vertex, "fsh" to sources.fragment), surface.format + ).getOrNull() + } + + /** What the renderer of [surface] sets on its programs, as the engine's own program for it declares. */ + private fun uniforms(surface: VfxSurface): List { + val common = listOf( + ShaderGraphUniform("ModelViewMat", "matrix4x4", Identity), + ShaderGraphUniform("ProjMat", "matrix4x4", Identity), + ShaderGraphUniform("ColorModulator", "float", listOf(1f, 1f, 1f, 1f)), + ShaderGraphUniform("FogStart", "float", listOf(0f)), + ShaderGraphUniform("FogEnd", "float", listOf(1f)), + ShaderGraphUniform("FogShape", "int", listOf(0f)), + ShaderGraphUniform("GameTime", "float", listOf(0f)), + ShaderGraphUniform("ScreenSize", "float", listOf(1f, 1f)), + ShaderGraphUniform("GlowPass", "float", listOf(0f)), + ) + val perDraw = listOf( + ShaderGraphUniform("BlendMode", "float", listOf(0f)), + ShaderGraphUniform("Softness", "float", listOf(0f)), + ShaderGraphUniform("Glow", "float", listOf(0f)), + ) + return common + when (surface) { + VfxSurface.PLANE -> listOf(ShaderGraphUniform("AlphaCutoff", "float", listOf(0.01f))) + VfxSurface.MESH -> perDraw + ShaderGraphUniform("Shaded", "float", listOf(0f)) + VfxSurface.RIBBON -> perDraw + } + } + + private val Identity = listOf(1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f) + private val Unsafe = Regex("[^a-z0-9_]") +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt new file mode 100644 index 000000000..8fce12591 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxMeshRenderer.kt @@ -0,0 +1,296 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.VertexFormat +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import net.minecraft.util.Mth +import org.joml.Matrix4f +import org.joml.Quaternionf +import org.lwjgl.BufferUtils +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.client.models.internal.utils.VboWrapper +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticleLook +import ru.hollowhorizon.hollowengine.common.registry.ModShaders +import ru.hollowhorizon.hollowengine.common.vfx.VfxLighting +import ru.hollowhorizon.hollowengine.common.vfx.VfxPrimitive +import java.nio.FloatBuffer + +/** + * Draws the built-in meshes, one instance per particle, one draw call per mesh and material. + * + * An instance carries its whole transform, so a mesh needs nothing per draw but its material. + */ +object VfxMeshRenderer { + /** Floats per instance: three axes and an origin, color, texture window, light. */ + private const val STRIDE = 22 + private const val STRIDE_BYTES = STRIDE * Float.SIZE_BYTES + private const val VERTEX_BYTES = VfxPrimitiveGeometry.FLOATS * Float.SIZE_BYTES + + private class GpuMesh(val vertices: VboWrapper, val indices: VboWrapper, val indexCount: Int) + + private class Batch(val primitive: VfxPrimitive, val key: VfxBatchKey, val shaded: Boolean) { + val draws = ArrayList() + var first = 0 + var count = 0 + var depth = 0f + val sorted: Boolean get() = key.blend == VfxBatchBlend.BLEND + } + + private val meshes = HashMap() + private val vaos = HashMap, Int>() + private var instanceBuffer: VboWrapper? = null + private var instanceCapacity = 0 + private var packed = FloatArray(STRIDE * 64) + private var upload: FloatBuffer = BufferUtils.createFloatBuffer(STRIDE * 64) + + private val batches = LinkedHashMap, Batch>() + + /** The batches of the last [render], in the order they were drawn; the glow pass draws them again. */ + private var drawn: List = emptyList() + private val matrix = Matrix4f() + private val rotation = Quaternionf() + + /** Frees the GL objects; the next draw builds them again. */ + fun invalidate() { + vaos.values.forEach(GL33::glDeleteVertexArrays) + vaos.clear() + meshes.values.forEach { + it.vertices.delete() + it.indices.delete() + } + meshes.clear() + instanceBuffer?.delete() + instanceBuffer = null + instanceCapacity = 0 + } + + fun render(draws: List, view: VfxView) { + drawn = emptyList() + if (draws.isEmpty()) return + val total = pack(draws, view) + if (total == 0) return + + val order = batches.values.filter { !it.sorted } + + batches.values.filter { it.sorted }.sortedByDescending { it.depth } + drawn = order + uploadInstances(total) + drawInstanced(order, view, glow = false) + } + + /** Draws the glow of the batches that have one, out of the instances [render] uploaded this frame. */ + fun renderGlow(view: VfxView) { + val glowing = drawn.filter { it.key.glow > 0f } + if (glowing.isNotEmpty()) drawInstanced(glowing, view, glow = true) + } + + private fun pack(draws: List, view: VfxView): Int { + batches.clear() + draws.forEach { draw -> + val key = VfxBatchKey.of(draw.spec.material, mixed = false, owner = draw) + batches.getOrPut(draw.primitive to key) { + Batch(draw.primitive, key, draw.spec.material.lighting == VfxLighting.WORLD) + }.draws += draw + } + + var total = 0 + batches.values.forEach { batch -> + batch.count = batch.draws.sumOf { it.batch.particles.count } + total += batch.count + } + if (packed.size < total * STRIDE) packed = FloatArray(Integer.highestOneBit(total.coerceAtLeast(1)) * 2 * STRIDE) + + var cursor = 0 + batches.values.forEach { batch -> + batch.first = cursor + var depthSum = 0f + batch.draws.forEach { draw -> + for (slot in 0 until draw.batch.particles.count) { + write(cursor * STRIDE, draw, slot) + depthSum += distanceSquared(cursor * STRIDE, view) + cursor++ + } + } + batch.depth = if (batch.count > 0) depthSum / batch.count else 0f + if (batch.sorted) sortBackToFront(batch, view) + } + return total + } + + private fun write(at: Int, draw: VfxMeshDraw, slot: Int) { + val batch = draw.batch + val particles = batch.particles + val material = draw.spec.material + val look = batch.look + val size = batch.lookOf(slot) + VfxParticleLook.SIZE + val spin = batch.lookOf(slot) + VfxParticleLook.ROTATION + val color = batch.lookOf(slot) + VfxParticleLook.COLOR + + matrix.set(batch.matrix).translate( + particles.positionX[slot] + batch.offset.x, + particles.positionY[slot] + batch.offset.y, + particles.positionZ[slot] + batch.offset.z, + ) + if (draw.spec.alignToVelocity) { + matrix.rotate( + VfxBillboards.facing(particles.velocityX[slot], particles.velocityY[slot], particles.velocityZ[slot]) + ) + } + matrix.rotate( + rotation.identity().rotateZYX( + (look[spin + 2] + batch.spin.z) * Mth.DEG_TO_RAD, + (look[spin + 1] + batch.spin.y) * Mth.DEG_TO_RAD, + (look[spin] + batch.spin.x) * Mth.DEG_TO_RAD, + ) + ) + matrix.scale( + look[size] * batch.sizeScale.x, + look[size + 1] * batch.sizeScale.y, + look[size + 2] * batch.sizeScale.z, + ) + + val region = material.uv + val cellWidth = region.width / batch.uvColumns + val cellHeight = region.height / batch.uvRows + val frame = particles.frame[slot].toInt().coerceIn(0, batch.uvColumns * batch.uvRows - 1) + val light = particles.light[slot] + val tint = batch.tint + + packed[at] = matrix.m00(); packed[at + 1] = matrix.m01(); packed[at + 2] = matrix.m02() + packed[at + 3] = matrix.m10(); packed[at + 4] = matrix.m11(); packed[at + 5] = matrix.m12() + packed[at + 6] = matrix.m20(); packed[at + 7] = matrix.m21(); packed[at + 8] = matrix.m22() + packed[at + 9] = matrix.m30(); packed[at + 10] = matrix.m31(); packed[at + 11] = matrix.m32() + packed[at + 12] = look[color] * tint[0] + packed[at + 13] = look[color + 1] * tint[1] + packed[at + 14] = look[color + 2] * tint[2] + packed[at + 15] = look[color + 3] * tint[3] + packed[at + 16] = region.u0 + (frame % batch.uvColumns) * cellWidth + packed[at + 17] = region.v0 + (frame / batch.uvColumns) * cellHeight + packed[at + 18] = cellWidth + packed[at + 19] = cellHeight + packed[at + 20] = (light and 0xFFFF).toFloat() + packed[at + 21] = (light shr 16 and 0xFFFF).toFloat() + } + + private fun distanceSquared(at: Int, view: VfxView): Float { + val dx = packed[at + 9] - view.eye.x + val dy = packed[at + 10] - view.eye.y + val dz = packed[at + 11] - view.eye.z + return dx * dx + dy * dy + dz * dz + } + + /** Few meshes are alpha blended at once, so a plain sort of the instances is enough. */ + private fun sortBackToFront(batch: Batch, view: VfxView) { + val start = batch.first + val items = (0 until batch.count).map { index -> + val at = (start + index) * STRIDE + distanceSquared(at, view) to packed.copyOfRange(at, at + STRIDE) + }.sortedByDescending { it.first } + items.forEachIndexed { index, (_, data) -> System.arraycopy(data, 0, packed, (start + index) * STRIDE, STRIDE) } + } + + private fun drawInstanced(order: List, view: VfxView, glow: Boolean) { + val engine = ModShaders.VFX_MESH ?: return + val previousVao = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) + val previousBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + try { + GL33.glDepthFunc(GL33.GL_LEQUAL) + order.forEach { batch -> + val shader = if (glow) { + VfxMaterialStates.glowShader(batch.key.shader, engine, VfxSurface.MESH) ?: return@forEach + } else { + batch.key.shader?.let { VfxShaders.surface(it, VfxSurface.MESH) } ?: engine + } + val mesh = gpuMesh(batch.primitive) + shader.setDefaultUniforms( + VertexFormat.Mode.TRIANGLES, view.modelView, view.projection, Minecraft.getInstance().window + ) + VfxMaterialStates.bindCommonSamplers(shader, VfxMaterialStates.texture(batch.key.texture)) + shader.safeGetUniform("Shaded").set(if (batch.shaded) 1f else 0f) + shader.safeGetUniform("BlendMode").set(VfxQuadPacker.blendMode(batch.draws.first().spec.material.blend)) + shader.safeGetUniform("Softness").set(batch.key.softness) + shader.safeGetUniform("Glow").set(batch.key.glow) + shader.safeGetUniform("GlowPass").set(if (glow) 1f else 0f) + if (shader !== engine) batch.draws.first().uniforms?.apply(shader) + shader.apply() + if (glow) VfxMaterialStates.applyGlow(batch.key) else VfxMaterialStates.apply(batch.key, premultiplied = false) + + RenderSystem.glBindVertexArray(vaoFor(shader, batch.primitive, mesh)) + instanceBuffer?.bind() + pointInstances(shader, batch.first.toLong() * STRIDE_BYTES) + GL33.glDrawElementsInstanced(GL33.GL_TRIANGLES, mesh.indexCount, GL33.GL_UNSIGNED_INT, 0L, batch.count) + shader.clear() + } + } finally { + VfxMaterialStates.restore() + GlStateManager._glUseProgram(0) + RenderSystem.glBindVertexArray(previousVao) + RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, previousBuffer) + } + } + + private fun uploadInstances(total: Int) { + val buffer = instanceBuffer ?: VboWrapper.createArrayBuffer().also { instanceBuffer = it } + if (instanceCapacity < total || upload.capacity() < total * STRIDE) { + val capacity = Integer.highestOneBit((total - 1).coerceAtLeast(63)) * 2 + upload = BufferUtils.createFloatBuffer(capacity * STRIDE) + instanceCapacity = capacity + buffer.bind() + GL33.glBufferData(GL33.GL_ARRAY_BUFFER, capacity.toLong() * STRIDE_BYTES, GL33.GL_STREAM_DRAW) + } + upload.clear() + upload.put(packed, 0, total * STRIDE) + upload.flip() + buffer.bind() + GL33.glBufferSubData(GL33.GL_ARRAY_BUFFER, 0L, upload) + } + + private fun gpuMesh(primitive: VfxPrimitive): GpuMesh = meshes.getOrPut(primitive) { + RenderSystem.glBindVertexArray(0) + val geometry = VfxPrimitiveMeshes.of(primitive) + val vertices = VboWrapper.createArrayBuffer().apply { + val data = BufferUtils.createFloatBuffer(geometry.vertices.size) + data.put(geometry.vertices).flip() + uploadData(data) + } + val indices = VboWrapper.createElementBuffer().apply { + val data = BufferUtils.createIntBuffer(geometry.indices.size) + data.put(geometry.indices).flip() + uploadData(data) + } + GpuMesh(vertices, indices, geometry.indices.size) + } + + private fun vaoFor(shader: ShaderInstance, primitive: VfxPrimitive, mesh: GpuMesh): Int = + vaos.getOrPut(shader to primitive) { + val vao = GL33.glGenVertexArrays() + GL33.glBindVertexArray(vao) + mesh.vertices.bind() + bindFloats(shader, "Position", 3, VERTEX_BYTES, 0L, divisor = 0) + bindFloats(shader, "Normal", 3, VERTEX_BYTES, 3L * Float.SIZE_BYTES, divisor = 0) + bindFloats(shader, "UV0", 2, VERTEX_BYTES, 6L * Float.SIZE_BYTES, divisor = 0) + instanceBuffer?.bind() + pointInstances(shader, 0L) + mesh.indices.bind() + vao + } + + private fun pointInstances(shader: ShaderInstance, base: Long) { + bindFloats(shader, "InstanceX", 3, STRIDE_BYTES, base) + bindFloats(shader, "InstanceY", 3, STRIDE_BYTES, base + 3L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceZ", 3, STRIDE_BYTES, base + 6L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceOrigin", 3, STRIDE_BYTES, base + 9L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceColor", 4, STRIDE_BYTES, base + 12L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceUv", 4, STRIDE_BYTES, base + 16L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceLight", 2, STRIDE_BYTES, base + 20L * Float.SIZE_BYTES) + } + + private fun bindFloats(shader: ShaderInstance, name: String, size: Int, stride: Int, offset: Long, divisor: Int = 1) { + val location = GL33.glGetAttribLocation(shader.id, name) + if (location == -1) return + GL33.glVertexAttribPointer(location, size, GL33.GL_FLOAT, false, stride, offset) + GL33.glEnableVertexAttribArray(location) + GL33.glVertexAttribDivisor(location, divisor) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxPrimitiveMeshes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxPrimitiveMeshes.kt new file mode 100644 index 000000000..9715fb8e9 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxPrimitiveMeshes.kt @@ -0,0 +1,147 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import ru.hollowhorizon.hollowengine.common.vfx.VfxPrimitive +import ru.hollowhorizon.hollowengine.common.vfx.VfxPrimitiveKind +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * A built-in mesh on the CPU, one block across: [FLOATS] floats per vertex (position, normal, + * texture coordinate) and counter-clockwise triangles seen from outside. + */ +class VfxPrimitiveGeometry(val vertices: FloatArray, val indices: IntArray) { + companion object { + const val FLOATS = 8 + } +} + +/** The geometry of every built-in mesh an effect has asked for, built once. */ +object VfxPrimitiveMeshes { + private val built = HashMap() + + @Synchronized + fun of(primitive: VfxPrimitive): VfxPrimitiveGeometry = built.getOrPut(primitive) { + when (primitive.kind) { + VfxPrimitiveKind.CUBE -> cube() + VfxPrimitiveKind.SPHERE -> sphere(primitive.segments) + VfxPrimitiveKind.CYLINDER -> cylinder(primitive.segments, primitive.caps) + } + } + + private class Builder { + val vertices = ArrayList() + val indices = ArrayList() + var count = 0 + + fun vertex(x: Float, y: Float, z: Float, nx: Float, ny: Float, nz: Float, u: Float, v: Float): Int { + vertices += x; vertices += y; vertices += z + vertices += nx; vertices += ny; vertices += nz + vertices += u; vertices += v + return count++ + } + + fun triangle(a: Int, b: Int, c: Int) { + indices += a; indices += b; indices += c + } + + fun build() = VfxPrimitiveGeometry(vertices.toFloatArray(), indices.toIntArray()) + } + + /** Six faces, each with the whole texture; the two edge axes of a face cross into its normal. */ + private fun cube(): VfxPrimitiveGeometry { + val builder = Builder() + val faces = listOf( + floatArrayOf(1f, 0f, 0f, 0f, 0f, -1f, 0f, 1f, 0f), + floatArrayOf(-1f, 0f, 0f, 0f, 0f, 1f, 0f, 1f, 0f), + floatArrayOf(0f, 1f, 0f, 1f, 0f, 0f, 0f, 0f, -1f), + floatArrayOf(0f, -1f, 0f, 1f, 0f, 0f, 0f, 0f, 1f), + floatArrayOf(0f, 0f, 1f, 1f, 0f, 0f, 0f, 1f, 0f), + floatArrayOf(0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f), + ) + faces.forEach { face -> + val first = builder.count + for ((a, b) in listOf(-1f to -1f, 1f to -1f, 1f to 1f, -1f to 1f)) { + builder.vertex( + (face[0] + face[3] * a + face[6] * b) * 0.5f, + (face[1] + face[4] * a + face[7] * b) * 0.5f, + (face[2] + face[5] * a + face[8] * b) * 0.5f, + face[0], face[1], face[2], + (a + 1f) * 0.5f, 1f - (b + 1f) * 0.5f, + ) + } + builder.triangle(first, first + 1, first + 2) + builder.triangle(first, first + 2, first + 3) + } + return builder.build() + } + + private fun sphere(segments: Int): VfxPrimitiveGeometry { + val builder = Builder() + val rings = (segments / 2).coerceAtLeast(2) + for (ring in 0..rings) { + val phi = PI.toFloat() * ring / rings + for (slice in 0..segments) { + val theta = TAU * slice / segments + val nx = sin(phi) * cos(theta) + val ny = cos(phi) + val nz = sin(phi) * sin(theta) + val u = slice.toFloat() / segments + builder.vertex(nx * 0.5f, ny * 0.5f, nz * 0.5f, nx, ny, nz, u, ring.toFloat() / rings) + } + } + val row = segments + 1 + for (ring in 0 until rings) { + for (slice in 0 until segments) { + val a = ring * row + slice + val b = a + row + builder.triangle(a, a + 1, b) + builder.triangle(a + 1, b + 1, b) + } + } + return builder.build() + } + + private fun cylinder(segments: Int, caps: Boolean): VfxPrimitiveGeometry { + val builder = Builder() + for (slice in 0..segments) { + val theta = TAU * slice / segments + val nx = cos(theta) + val nz = sin(theta) + val u = slice.toFloat() / segments + builder.vertex(nx * 0.5f, 0.5f, nz * 0.5f, nx, 0f, nz, u, 0f) + builder.vertex(nx * 0.5f, -0.5f, nz * 0.5f, nx, 0f, nz, u, 1f) + } + for (slice in 0 until segments) { + val top = slice * 2 + val bottom = top + 1 + builder.triangle(top, top + 2, bottom) + builder.triangle(top + 2, bottom + 2, bottom) + } + if (caps) { + cap(builder, segments, 0.5f) + cap(builder, segments, -0.5f) + } + return builder.build() + } + + private fun cap(builder: Builder, segments: Int, y: Float) { + val up = if (y > 0f) 1f else -1f + val center = builder.vertex(0f, y, 0f, 0f, up, 0f, 0.5f, 0.5f) + val first = builder.count + for (slice in 0..segments) { + val theta = TAU * slice / segments + builder.vertex( + cos(theta) * 0.5f, y, sin(theta) * 0.5f, + 0f, up, 0f, + 0.5f + cos(theta) * 0.5f, 0.5f + sin(theta) * 0.5f, + ) + } + for (slice in 0 until segments) { + val current = first + slice + if (up > 0f) builder.triangle(center, current + 1, current) else builder.triangle(center, current, current + 1) + } + } + + private val TAU = (PI * 2.0).toFloat() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadPacker.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadPacker.kt new file mode 100644 index 000000000..ee1889c96 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadPacker.kt @@ -0,0 +1,395 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import net.minecraft.util.Mth +import org.joml.Matrix4f +import org.joml.Quaternionf +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticleLook +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxQuadPacker.Companion.STRIDE +import ru.hollowhorizon.hollowengine.common.vfx.VfxBlend +import ru.hollowhorizon.hollowengine.common.vfx.VfxFacing +import ru.hollowhorizon.hollowengine.common.vfx.VfxMaterialSpec +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * How a batch is blended into the frame. + * + * [MIXED] is the one that lets different blend modes share a draw: with premultiplied alpha and a + * `ONE, ONE_MINUS_SRC_ALPHA` blend, an alpha-blended particle writes its alpha, an additive one + * writes zero alpha, and an opaque one writes one, so the three differ per particle rather than per + * draw. Only multiplying needs a blend function of its own. A shader of the author does not know + * about the trick, so its batches keep a blend function per mode. + */ +internal enum class VfxBatchBlend { + MIXED, OPAQUE, BLEND, ADDITIVE, MULTIPLY; + + companion object { + fun of(blend: VfxBlend, mixed: Boolean) = when { + blend == VfxBlend.MULTIPLY -> MULTIPLY + mixed -> MIXED + blend == VfxBlend.OPAQUE -> OPAQUE + blend == VfxBlend.ADDITIVE -> ADDITIVE + else -> BLEND + } + } +} + +/** + * What decides whether two draws can share a draw call. + * + * A draw with a shader of its own also carries uniforms of its own, so [owner] keeps it apart. Planes + * of the engine program carry softness and glow per particle, so only the other draws are split by + * them. + */ +internal data class VfxBatchKey( + val texture: String, + val blend: VfxBatchBlend, + val cull: Boolean, + val depthTest: Boolean, + val depthWrite: Boolean, + val shader: String? = null, + val owner: Any? = null, + val softness: Float = 0f, + val glow: Float = 0f, +) { + companion object { + fun of(material: VfxMaterialSpec, mixed: Boolean, owner: Any?): VfxBatchKey { + val custom = material.shader != null + val perParticle = mixed && (!custom || VfxGraphMaterials.isGraph(material.shader)) + return VfxBatchKey( + texture = material.texture, + blend = VfxBatchBlend.of(material.blend, perParticle), + cull = material.cull, + depthTest = material.depthTest, + depthWrite = material.depthWrite, + shader = material.shader, + owner = if (custom) owner else null, + softness = if (perParticle) 0f else material.softness, + glow = if (perParticle) 0f else material.effectiveGlow(), + ) + } + } +} + +/** One draw call, where its particles start in the packed data, how many, and how far away. */ +internal class VfxQuadBatch(val key: VfxBatchKey) { + val draws = ArrayList() + var first = 0 + var count = 0 + var depth = 0f + + /** Whether anything in the batch is alpha blended, which is what makes its order matter. */ + val sorted: Boolean + get() = key.blend == VfxBatchBlend.BLEND || + key.blend == VfxBatchBlend.MIXED && draws.any { it.plane.material.blend == VfxBlend.BLEND } + + /** The uniforms of the one draw a batch with a shader of its own holds. */ + val uniforms: VfxUniformValues? get() = draws.firstOrNull()?.uniforms + + /** Whether anything in the batch glows, which is what draws it again in the glow pass. */ + val glows: Boolean get() = draws.any { it.plane.material.effectiveGlow() > 0f } +} + +/** + * Turns the collected planes into instance data, grouped into batches and in draw order. + */ +internal class VfxQuadPacker { + /** Particles written in draw order, [STRIDE] floats each. */ + var packed = FloatArray(STRIDE * 256) + private set + + val batches = ArrayList() + + private val byKey = LinkedHashMap() + private var staging = FloatArray(STRIDE * 256) + private var order = IntArray(256) + private var depths = FloatArray(256) + private val edges = FloatArray(6) + + /** Packs [draws] as seen from [view]; returns how many particles there are in total. */ + fun pack(draws: List, view: VfxView): Int { + byKey.clear() + batches.clear() + if (draws.isEmpty()) return 0 + draws.forEach { draw -> + val key = VfxBatchKey.of(draw.plane.material, mixed = true, owner = draw) + byKey.getOrPut(key) { VfxQuadBatch(key) }.draws += draw + } + + var total = 0 + byKey.values.forEach { batch -> + batch.count = batch.draws.sumOf { it.batch.particles.count } + total += batch.count + } + batches.clear() + if (total == 0) return 0 + ensureCapacity(total) + + var cursor = 0 + byKey.values.forEach { batch -> + batch.first = cursor + fill(batch, view) + cursor += batch.count + } + + byKey.values.filterTo(batches) { !it.sorted && it.count > 0 } + byKey.values.filter { it.sorted && it.count > 0 }.sortedByDescending { it.depth }.forEach(batches::add) + return total + } + + private fun fill(batch: VfxQuadBatch, view: VfxView) { + val sorted = batch.sorted + val target = if (sorted) staging else packed + var offset = if (sorted) 0 else batch.first * STRIDE + var depthSum = 0f + + batch.draws.forEach { draw -> + val glow = draw.plane.material.effectiveGlow() + for (slot in 0 until draw.batch.particles.count) { + write(target, offset, draw, slot, glow, view) + if (sorted) { + val index = offset / STRIDE + val depth = distanceSquared(target, offset, view) + depths[index] = depth + order[index] = index + depthSum += depth + } + offset += STRIDE + } + } + + if (!sorted) return + batch.depth = if (batch.count > 0) depthSum / batch.count else 0f + + for (index in 1 until batch.count) { + val item = order[index] + val depth = depths[item] + var scan = index - 1 + while (scan >= 0 && depths[order[scan]] < depth) { + order[scan + 1] = order[scan] + scan-- + } + order[scan + 1] = item + } + for (position in 0 until batch.count) { + System.arraycopy(staging, order[position] * STRIDE, packed, (batch.first + position) * STRIDE, STRIDE) + } + } + + private fun distanceSquared(data: FloatArray, offset: Int, view: VfxView): Float { + val dx = data[offset] - view.eye.x + val dy = data[offset + 1] - view.eye.y + val dz = data[offset + 2] - view.eye.z + return dx * dx + dy * dy + dz * dz + } + + private fun write(out: FloatArray, base: Int, draw: VfxQuadDraw, slot: Int, glow: Float, view: VfxView) { + val plane = draw.plane + val batch = draw.batch + val particles = batch.particles + val matrix = batch.matrix + + val px = particles.positionX[slot] + batch.offset.x + val py = particles.positionY[slot] + batch.offset.y + val pz = particles.positionZ[slot] + batch.offset.z + val cx = matrix.m00() * px + matrix.m10() * py + matrix.m20() * pz + matrix.m30() + val cy = matrix.m01() * px + matrix.m11() * py + matrix.m21() * pz + matrix.m31() + val cz = matrix.m02() * px + matrix.m12() * py + matrix.m22() * pz + matrix.m32() + + val look = batch.look + val at = batch.lookOf(slot) + val width = look[at + VfxParticleLook.SIZE] * batch.sizeScale.x + val height = look[at + VfxParticleLook.SIZE + 1] * batch.sizeScale.y + val roll = look[at + VfxParticleLook.ROTATION + 2] + batch.spin.z + when (plane.facing) { + VfxFacing.CAMERA -> VfxBillboards.cameraEdges(edges, matrix, view, roll, width, height) + VfxFacing.CAMERA_AXIS -> VfxBillboards.axisEdges( + edges, matrix, view, cx, cy, cz, + plane.facingAxis.x, plane.facingAxis.y, plane.facingAxis.z, + width, height, + ) + + VfxFacing.VELOCITY -> VfxBillboards.axisEdges( + edges, matrix, view, cx, cy, cz, + particles.velocityX[slot], particles.velocityY[slot], particles.velocityZ[slot], + width, height, + ) + + VfxFacing.NONE -> VfxBillboards.rotatedEdges( + edges, matrix, + look[at + VfxParticleLook.ROTATION] + batch.spin.x, look[at + VfxParticleLook.ROTATION + 1] + batch.spin.y, roll, + width, height, + ) + } + + val region = plane.material.uv + val columns = batch.uvColumns + val rows = batch.uvRows + val cellWidth = region.width / columns + val cellHeight = region.height / rows + val frame = particles.frame[slot].toInt().coerceIn(0, columns * rows - 1) + val light = particles.light[slot] + val tint = batch.tint + + val color = at + VfxParticleLook.COLOR + out[base] = cx + out[base + 1] = cy + out[base + 2] = cz + System.arraycopy(edges, 0, out, base + 3, 6) + out[base + 9] = look[color] * tint[0] + out[base + 10] = look[color + 1] * tint[1] + out[base + 11] = look[color + 2] * tint[2] + out[base + 12] = look[color + 3] * tint[3] + out[base + 13] = region.u0 + (frame % columns) * cellWidth + out[base + 14] = region.v0 + (frame / columns) * cellHeight + out[base + 15] = cellWidth + out[base + 16] = cellHeight + out[base + 17] = (light and 0xFFFF).toFloat() + out[base + 18] = (light shr 16 and 0xFFFF).toFloat() + out[base + 19] = blendMode(plane.material.blend) + out[base + 20] = plane.material.softness + out[base + 21] = glow + } + + private fun ensureCapacity(count: Int) { + if (packed.size >= count * STRIDE) return + val capacity = Integer.highestOneBit((count - 1).coerceAtLeast(255)) * 2 + packed = FloatArray(capacity * STRIDE) + staging = FloatArray(capacity * STRIDE) + order = IntArray(capacity) + depths = FloatArray(capacity) + } + + companion object { + /** Floats per particle: center, right edge, up edge, color, uv window, light, blend mode, softness, glow. */ + const val STRIDE = 22 + + /** The blend mode as the particle shader reads it: 0 alpha, 1 additive, 2 opaque, 3 multiply. */ + fun blendMode(blend: VfxBlend): Float = when (blend) { + VfxBlend.BLEND -> 0f + VfxBlend.ADDITIVE -> 1f + VfxBlend.OPAQUE -> 2f + VfxBlend.MULTIPLY -> 3f + } + } +} + +/** + * The two edge vectors of a quad in the space of the view, however it faces the camera. + */ +internal object VfxBillboards { + private const val EPSILON = 1.0e-6f + private const val DEG_TO_RAD = (Math.PI / 180.0).toFloat() + + /** Turns a velocity into a rotation that points a mesh or a model along it. */ + fun facing(x: Float, y: Float, z: Float): Quaternionf { + if (x * x + y * y + z * z < EPSILON) return Quaternionf() + val yaw = Mth.atan2(x.toDouble(), z.toDouble()).toFloat() + val pitch = Mth.atan2(y.toDouble(), Mth.sqrt(x * x + z * z).toDouble()).toFloat() + return Quaternionf().rotateY(yaw).rotateX(-pitch) + } + + fun cameraEdges( + out: FloatArray, + matrix: Matrix4f, + view: VfxView, + rollDegrees: Float, + width: Float, + height: Float, + ) { + val scale = sqrt(matrix.m00() * matrix.m00() + matrix.m01() * matrix.m01() + matrix.m02() * matrix.m02()) + val roll = rollDegrees * DEG_TO_RAD + val c = cos(roll) + val s = sin(roll) + val w = width * scale + val h = height * scale + out[0] = (view.right.x * c + view.up.x * s) * w + out[1] = (view.right.y * c + view.up.y * s) * w + out[2] = (view.right.z * c + view.up.z * s) * w + out[3] = (view.up.x * c - view.right.x * s) * h + out[4] = (view.up.y * c - view.right.y * s) * h + out[5] = (view.up.z * c - view.right.z * s) * h + } + + /** + * A quad along an axis given in the space of the particles, turned around it to face the + * camera. With no axis to speak of, it simply faces the camera. + */ + fun axisEdges( + out: FloatArray, + matrix: Matrix4f, + view: VfxView, + cx: Float, + cy: Float, + cz: Float, + axisX: Float, + axisY: Float, + axisZ: Float, + width: Float, + height: Float, + ) { + var ax = matrix.m00() * axisX + matrix.m10() * axisY + matrix.m20() * axisZ + var ay = matrix.m01() * axisX + matrix.m11() * axisY + matrix.m21() * axisZ + var az = matrix.m02() * axisX + matrix.m12() * axisY + matrix.m22() * axisZ + val axisLength = sqrt(ax * ax + ay * ay + az * az) + if (axisLength < EPSILON) { + cameraEdges(out, matrix, view, 0f, width, height) + return + } + ax /= axisLength + ay /= axisLength + az /= axisLength + val scale = sqrt(matrix.m00() * matrix.m00() + matrix.m01() * matrix.m01() + matrix.m02() * matrix.m02()) + + val tx = view.eye.x - cx + val ty = view.eye.y - cy + val tz = view.eye.z - cz + var rx = ay * tz - az * ty + var ry = az * tx - ax * tz + var rz = ax * ty - ay * tx + val rightLength = sqrt(rx * rx + ry * ry + rz * rz) + if (rightLength < EPSILON) { + rx = view.right.x; ry = view.right.y; rz = view.right.z + } else { + rx /= rightLength; ry /= rightLength; rz /= rightLength + } + out[0] = rx * width * scale + out[1] = ry * width * scale + out[2] = rz * width * scale + out[3] = ax * height * scale + out[4] = ay * height * scale + out[5] = az * height * scale + } + + fun rotatedEdges( + out: FloatArray, + matrix: Matrix4f, + degreesX: Float, + degreesY: Float, + degreesZ: Float, + width: Float, + height: Float, + ) { + val sx = sin(degreesX * DEG_TO_RAD) + val cx = cos(degreesX * DEG_TO_RAD) + val sy = sin(degreesY * DEG_TO_RAD) + val cy = cos(degreesY * DEG_TO_RAD) + val sz = sin(degreesZ * DEG_TO_RAD) + val cz = cos(degreesZ * DEG_TO_RAD) + + val lx = cz * cy * width + val ly = sz * cy * width + val lz = -sy * width + val vx = (cz * sy * sx - sz * cx) * height + val vy = (sz * sy * sx + cz * cx) * height + val vz = cy * sx * height + + out[0] = matrix.m00() * lx + matrix.m10() * ly + matrix.m20() * lz + out[1] = matrix.m01() * lx + matrix.m11() * ly + matrix.m21() * lz + out[2] = matrix.m02() * lx + matrix.m12() * ly + matrix.m22() * lz + out[3] = matrix.m00() * vx + matrix.m10() * vy + matrix.m20() * vz + out[4] = matrix.m01() * vx + matrix.m11() * vy + matrix.m21() * vz + out[5] = matrix.m02() * vx + matrix.m12() * vy + matrix.m22() * vz + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt new file mode 100644 index 000000000..b4f097a68 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxQuadRenderer.kt @@ -0,0 +1,213 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.* +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import org.lwjgl.BufferUtils +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.client.models.internal.utils.VboWrapper +import ru.hollowhorizon.hollowengine.common.registry.ModShaders +import java.nio.FloatBuffer +import java.util.* + +/** + * Draws billboard particles, one instanced quad per particle, one draw call per batch. + * + * The engine program draws everything but the materials with a shader of their own, and + * alpha-blended, additive and opaque particles share one draw through premultiplied alpha (see + * [VfxBatchBlend]). With a shader pack the planes are drawn over the pack's finished image, so the + * pack never replaces the program. + */ +object VfxQuadRenderer { + private const val STRIDE = VfxQuadPacker.STRIDE + private const val STRIDE_BYTES = STRIDE * Float.SIZE_BYTES + + private val packer = VfxQuadPacker() + + private val vaos = IdentityHashMap() + private var quadBuffer: VboWrapper? = null + private var indexBuffer: VboWrapper? = null + private var instanceBuffer: VboWrapper? = null + private var instanceCapacity = 0 + private var upload: FloatBuffer = BufferUtils.createFloatBuffer(STRIDE * 256) + + /** Frees the GL objects; the next draw builds them again. */ + fun invalidate() { + vaos.values.forEach(GL33::glDeleteVertexArrays) + vaos.clear() + quadBuffer?.delete() + indexBuffer?.delete() + instanceBuffer?.delete() + quadBuffer = null + indexBuffer = null + instanceBuffer = null + instanceCapacity = 0 + } + + /** Draws everything in [draws] as seen from [view]. */ + fun render(draws: List, view: VfxView) { + val total = packer.pack(draws, view) + if (total == 0) return + draw(total, view) + } + + /** + * Draws the glow of the batches that have one, out of the instances [render] uploaded this frame. + */ + fun renderGlow(view: VfxView) { + val engine = ModShaders.VFX_PARTICLE ?: return + val glowing = packer.batches.filter { it.glows } + if (glowing.isEmpty()) return + withInstanceState(upload = 0) { + GL33.glDepthFunc(GL33.GL_LEQUAL) + glowing.forEach { batch -> + val shader = VfxMaterialStates.glowShader(batch.key.shader, engine, VfxSurface.PLANE) + ?: return@forEach + drawBatch(shader, engine, batch, view, glow = true) + } + } + } + + /** + * The engine program draws every batch but the ones whose material names a shader of its own; + * those get theirs, falling back to the engine program when it failed to load. + */ + private fun draw(total: Int, view: VfxView) { + val engine = ModShaders.VFX_PARTICLE ?: return + withInstanceState(upload = total) { + GL33.glDepthFunc(GL33.GL_LEQUAL) + + packer.batches.forEach { batch -> + val shader = batch.key.shader?.let { VfxShaders.surface(it, VfxSurface.PLANE) } ?: engine + drawBatch(shader, engine, batch, view, glow = false) + } + } + } + + private fun drawBatch(shader: ShaderInstance, engine: ShaderInstance, batch: VfxQuadBatch, view: VfxView, glow: Boolean) { + RenderSystem.setShader { shader } + shader.setDefaultUniforms( + VertexFormat.Mode.TRIANGLES, view.modelView, view.projection, Minecraft.getInstance().window + ) + VfxMaterialStates.bindCommonSamplers(shader, VfxMaterialStates.texture(batch.key.texture)) + if (shader !== engine) batch.uniforms?.apply(shader) + shader.safeGetUniform("GlowPass").set(if (glow) 1f else 0f) + shader.apply() + if (glow) { + VfxMaterialStates.applyGlow(batch.key) + } else { + VfxMaterialStates.apply(batch.key, premultiplied = shader === engine || VfxGraphMaterials.isGraph(batch.key.shader)) + } + drawInstances(shader, batch) + shader.clear() + } + + /** Runs [body] with the quad buffers bound, after uploading [upload] instances when there are any. */ + private inline fun withInstanceState(upload: Int, body: () -> Unit) { + val previousVao = GL33.glGetInteger(GL33.GL_VERTEX_ARRAY_BINDING) + val previousBuffer = GL33.glGetInteger(GL33.GL_ELEMENT_ARRAY_BUFFER_BINDING) + val previousTexture = GL33.glGetInteger(GL33.GL_ACTIVE_TEXTURE) + + ensureBuffers() + if (upload > 0) uploadInstances(upload) + try { + body() + } finally { + VfxMaterialStates.restore() + GlStateManager._glUseProgram(0) + RenderSystem.activeTexture(previousTexture) + RenderSystem.glBindVertexArray(previousVao) + RenderSystem.glBindBuffer(GL33.GL_ELEMENT_ARRAY_BUFFER, previousBuffer) + } + } + + private fun drawInstances(shader: ShaderInstance, batch: VfxQuadBatch) { + RenderSystem.glBindVertexArray(vaoFor(shader)) + instanceBuffer?.bind() + pointInstances(shader, batch.first.toLong() * STRIDE_BYTES) + GL33.glDrawElementsInstanced(GL33.GL_TRIANGLES, 6, GL33.GL_UNSIGNED_INT, 0L, batch.count) + } + + private fun uploadInstances(total: Int) { + if (instanceCapacity < total || upload.capacity() < total * STRIDE) { + val capacity = Integer.highestOneBit((total - 1).coerceAtLeast(255)) * 2 + upload = BufferUtils.createFloatBuffer(capacity * STRIDE) + instanceCapacity = capacity + instanceBuffer?.bind() + GL33.glBufferData(GL33.GL_ARRAY_BUFFER, capacity.toLong() * STRIDE_BYTES, GL33.GL_STREAM_DRAW) + } + upload.clear() + upload.put(packer.packed, 0, total * STRIDE) + upload.flip() + instanceBuffer?.bind() + GL33.glBufferSubData(GL33.GL_ARRAY_BUFFER, 0L, upload) + } + + private fun ensureBuffers() { + if (quadBuffer != null) return + RenderSystem.glBindVertexArray(0) + + quadBuffer = VboWrapper.createArrayBuffer().apply { + val corners = BufferUtils.createFloatBuffer(4 * 5) + corners.put(-0.5f).put(-0.5f).put(0f).put(0f).put(1f) + corners.put(0.5f).put(-0.5f).put(0f).put(1f).put(1f) + corners.put(0.5f).put(0.5f).put(0f).put(1f).put(0f) + corners.put(-0.5f).put(0.5f).put(0f).put(0f).put(0f) + corners.flip() + uploadData(corners) + } + indexBuffer = VboWrapper.createElementBuffer().apply { + val indices = BufferUtils.createIntBuffer(6) + indices.put(0).put(1).put(2).put(0).put(2).put(3) + indices.flip() + uploadData(indices) + } + instanceBuffer = VboWrapper.createArrayBuffer() + instanceCapacity = 0 + } + + private fun vaoFor(shader: ShaderInstance): Int = vaos.getOrPut(shader) { + val vao = GL33.glGenVertexArrays() + GL33.glBindVertexArray(vao) + + quadBuffer?.bind() + bindFloats(shader, "Position", 3, 5 * Float.SIZE_BYTES, 0L, divisor = 0) + bindFloats(shader, "UV0", 2, 5 * Float.SIZE_BYTES, 3L * Float.SIZE_BYTES, divisor = 0) + + instanceBuffer?.bind() + pointInstances(shader, 0L) + + indexBuffer?.bind() + vao + } + + /** Points the per-particle attributes at [base] bytes into the instance buffer. */ + private fun pointInstances(shader: ShaderInstance, base: Long) { + bindFloats(shader, "InstanceCenter", 3, STRIDE_BYTES, base) + bindFloats(shader, "InstanceRight", 3, STRIDE_BYTES, base + 3L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceUp", 3, STRIDE_BYTES, base + 6L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceColor", 4, STRIDE_BYTES, base + 9L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceUv", 4, STRIDE_BYTES, base + 13L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceLight", 2, STRIDE_BYTES, base + 17L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceBlend", 1, STRIDE_BYTES, base + 19L * Float.SIZE_BYTES) + bindFloats(shader, "InstanceMaterial", 2, STRIDE_BYTES, base + 20L * Float.SIZE_BYTES) + } + + private fun bindFloats( + shader: ShaderInstance, + name: String, + size: Int, + stride: Int, + offset: Long, + divisor: Int = 1, + ) { + val location = GL33.glGetAttribLocation(shader.id, name) + if (location == -1) return + + GL33.glVertexAttribPointer(location, size, GL33.GL_FLOAT, false, stride, offset) + GL33.glEnableVertexAttribArray(location) + if (divisor != 0) GL33.glVertexAttribDivisor(location, divisor) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRenderSupport.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRenderSupport.kt new file mode 100644 index 000000000..093429e9e --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRenderSupport.kt @@ -0,0 +1,215 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.pipeline.RenderTarget +import com.mojang.blaze3d.pipeline.TextureTarget +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.MeshData +import com.mojang.blaze3d.vertex.VertexBuffer +import com.mojang.blaze3d.vertex.VertexFormat +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.packs.resources.ResourceProvider +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.models.internal.manager.HollowModelManager +import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.VfxMaterialSpec + +/** + * The blend, depth and culling state a batch of surfaces is drawn with. + */ +internal object VfxMaterialStates { + fun apply(key: VfxBatchKey, premultiplied: Boolean) { + applyBlend(key.blend, premultiplied) + if (key.cull) RenderSystem.enableCull() else RenderSystem.disableCull() + if (key.depthTest) RenderSystem.enableDepthTest() else RenderSystem.disableDepthTest() + RenderSystem.depthMask(key.depthWrite) + } + + /** What a surface draws its glow with: added up, tested against the frame depth, never written to it. */ + fun applyGlow(key: VfxBatchKey) { + RenderSystem.enableBlend() + RenderSystem.blendEquation(GL33.GL_FUNC_ADD) + RenderSystem.blendFunc(GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ONE) + if (key.cull) RenderSystem.enableCull() else RenderSystem.disableCull() + if (key.depthTest) RenderSystem.enableDepthTest() else RenderSystem.disableDepthTest() + RenderSystem.depthMask(false) + } + + /** + * The program a surface draws its glow with: the engine's, or a shader of the author that + * declares `GlowPass` and so knows what to write in it; null when there is none. + */ + fun glowShader(custom: String?, engine: ShaderInstance, surface: VfxSurface): ShaderInstance? { + custom ?: return engine + if (VfxShaderDeclarations.of(custom)?.drawsGlow != true) return null + return VfxShaders.surface(custom, surface) + } + + fun restore() { + RenderSystem.depthMask(true) + RenderSystem.enableDepthTest() + RenderSystem.enableBlend() + RenderSystem.blendEquation(GL33.GL_FUNC_ADD) + RenderSystem.defaultBlendFunc() + RenderSystem.enableCull() + } + + fun texture(location: String): Int = Minecraft.getInstance().textureManager.getTexture(location.rl).id + + /** Binds what every shader of a surface may sample besides its texture: light and the scene. */ + fun bindCommonSamplers(shader: ShaderInstance, texture: Int) { + shader.setSampler("Sampler0", texture) + shader.setSampler("Sampler2", HollowModelManager.lightTexture.id) + VfxSceneTextures.bind(shader) + } + + private fun applyBlend(blend: VfxBatchBlend, premultiplied: Boolean) { + RenderSystem.blendEquation(GL33.GL_FUNC_ADD) + when (blend) { + VfxBatchBlend.OPAQUE -> RenderSystem.disableBlend() + VfxBatchBlend.MIXED -> { + RenderSystem.enableBlend() + RenderSystem.blendFunc(GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA) + } + + VfxBatchBlend.BLEND -> { + RenderSystem.enableBlend() + RenderSystem.defaultBlendFunc() + } + + VfxBatchBlend.ADDITIVE -> { + RenderSystem.enableBlend() + RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE) + } + + VfxBatchBlend.MULTIPLY -> { + RenderSystem.enableBlend() + if (premultiplied) { + RenderSystem.blendFunc(GlStateManager.SourceFactor.DST_COLOR, GlStateManager.DestFactor.ZERO) + } else { + RenderSystem.blendFunc( + GlStateManager.SourceFactor.DST_COLOR, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA + ) + } + } + } + } +} + +/** + * How much a material glows: what the effect set, or once for a material graph that has an emission + * when the effect set nothing, since the emission is there to glow. + */ +internal fun VfxMaterialSpec.effectiveGlow(): Float = when { + glow > 0f -> glow + VfxGraphMaterials.emits(shader) -> 1f + else -> 0f +} + +/** + * The core shaders effects name in their materials and post effects, loaded on first use. + */ +object VfxShaders { + private val loaded = HashMap, ShaderInstance?>() + + fun get(location: String, format: VertexFormat): ShaderInstance? = loaded.getOrPut(location to format) { + load(location, format) + } + + /** What [surface] draws with when its material names [location]: a core shader, or a material graph. */ + fun surface(location: String, surface: VfxSurface): ShaderInstance? = + if (VfxGraphMaterials.isGraph(location)) VfxGraphMaterials.program(location, surface) else get(location, surface.format) + + /** Resource packs changed: every shader is read again the next time it is drawn. */ + fun clear() { + loaded.values.filterNotNull().forEach(ShaderInstance::close) + loaded.clear() + VfxGraphMaterials.clear() + VfxShaderDeclarations.clear() + VfxQuadRenderer.invalidate() + VfxMeshRenderer.invalidate() + } + + private fun load(location: String, format: VertexFormat): ShaderInstance? { + val id = ResourceLocation.tryParse(location) ?: return null + val alias = "$ALIAS_FOLDER/${id.namespace}/${id.path}" + val resources = Minecraft.getInstance().resourceManager + val provider = ResourceProvider { wanted -> + val path = wanted.path + val prefix = "shaders/core/$ALIAS_FOLDER/" + if (wanted.namespace == ResourceLocation.DEFAULT_NAMESPACE && path.startsWith(prefix)) { + val rest = path.removePrefix(prefix) + val namespace = rest.substringBefore('/') + val file = "shaders/core/${rest.substringAfter('/')}" + resources.getResource(ResourceLocation.fromNamespaceAndPath(namespace, file)) + } else { + resources.getResource(wanted) + } + } + return try { + ShaderInstance(provider, alias, format) + } catch (e: Exception) { + HollowEngine.LOGGER.warn("Could not load the effect shader {}: {}", location, e.message) + null + } + } + + private const val ALIAS_FOLDER = "hollowengine_vfx" +} + +/** + * Copies of the color and depth of the frame being drawn into, for shaders that read the scene: + * `SceneColor` for distortion and post effects, `SceneDepth` for soft intersections. + */ +object VfxSceneTextures { + private var copy: TextureTarget? = null + + /** Copies what [source] holds right now; everything drawn afterwards is not in the copy. */ + fun capture(source: RenderTarget) { + val width = source.width + val height = source.height + val target = copy?.takeIf { it.width == width && it.height == height } + ?: TextureTarget(width, height, true, Minecraft.ON_OSX).also { + copy?.destroyBuffers() + copy = it + } + + val scissor = GL33.glIsEnabled(GL33.GL_SCISSOR_TEST) + if (scissor) GL33.glDisable(GL33.GL_SCISSOR_TEST) + GlStateManager._glBindFramebuffer(GL33.GL_READ_FRAMEBUFFER, source.frameBufferId) + GlStateManager._glBindFramebuffer(GL33.GL_DRAW_FRAMEBUFFER, target.frameBufferId) + GL33.glBlitFramebuffer( + 0, 0, width, height, 0, 0, width, height, + GL33.GL_COLOR_BUFFER_BIT or GL33.GL_DEPTH_BUFFER_BIT, GL33.GL_NEAREST, + ) + if (scissor) GL33.glEnable(GL33.GL_SCISSOR_TEST) + source.bindWrite(false) + } + + /** Binds the copies, and sets `ScreenSize` to their size: the preview draws into less than the window. */ + fun bind(shader: ShaderInstance) { + val target = copy ?: return + shader.setSampler("SceneColor", target.colorTextureId) + shader.setSampler("SceneDepth", target.depthTextureId) + shader.safeGetUniform("ScreenSize").set(target.width.toFloat(), target.height.toFloat()) + } +} + +/** + * Draws a finished buffer with [shader] and the matrices of [view], rather than whatever the render + * system holds, which in the editor preview is the matrices of the panel. + */ +internal fun VfxView.drawImmediate(mesh: MeshData, shader: ShaderInstance, prepare: (ShaderInstance) -> Unit) { + val buffer = mesh.drawState().format().immediateDrawVertexBuffer + buffer.bind() + buffer.upload(mesh) + shader.setDefaultUniforms(mesh.drawState().mode(), modelView, projection, Minecraft.getInstance().window) + prepare(shader) + shader.apply() + buffer.draw() + shader.clear() + VertexBuffer.unbind() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRibbonRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRibbonRenderer.kt new file mode 100644 index 000000000..9bf212a7a --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRibbonRenderer.kt @@ -0,0 +1,144 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.BufferBuilder +import com.mojang.blaze3d.vertex.DefaultVertexFormat +import com.mojang.blaze3d.vertex.Tesselator +import com.mojang.blaze3d.vertex.VertexFormat +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.common.registry.ModShaders +import ru.hollowhorizon.hollowengine.common.vfx.VfxBlend +import kotlin.math.sqrt + +/** + * Draws trails and beams: strips of points turned into quads that face the camera. + * + * Ribbons are built on the CPU every frame in the vanilla particle format, drawn by the engine + * ribbon program or by the shader the material names. + */ +object VfxRibbonRenderer { + private val side = FloatArray(3) + + /** + * Draws [draws] as seen from [view]; with [glow], only the glow of the ones that have one, which + * builds their strips again, since the pass comes after the frame has moved on to other surfaces. + */ + fun render(draws: List, view: VfxView, glow: Boolean = false) { + if (draws.isEmpty()) return + val engine = ModShaders.VFX_RIBBON ?: return + + val ordered = draws.filter { it.material.blend != VfxBlend.BLEND } + + draws.filter { it.material.blend == VfxBlend.BLEND }.sortedByDescending { depth(it, view) } + + ordered.forEach { draw -> + val material = draw.material + val glowAmount = material.effectiveGlow() + if (glow && glowAmount <= 0f) return@forEach + val shader = if (glow) { + VfxMaterialStates.glowShader(material.shader, engine, VfxSurface.RIBBON) ?: return@forEach + } else { + material.shader?.let { VfxShaders.surface(it, VfxSurface.RIBBON) } ?: engine + } + val texture = VfxMaterialStates.texture(material.texture) + + val builder = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE) + for (strip in 0 until draw.stripCount) { + quads(builder, draw, draw.strips[strip * 2], draw.strips[strip * 2 + 1], view) + } + val mesh = builder.build() ?: return@forEach + + if (draw.repeat) { + RenderSystem.bindTexture(texture) + RenderSystem.texParameter(GL33.GL_TEXTURE_2D, GL33.GL_TEXTURE_WRAP_S, GL33.GL_REPEAT) + RenderSystem.texParameter(GL33.GL_TEXTURE_2D, GL33.GL_TEXTURE_WRAP_T, GL33.GL_REPEAT) + } + val key = VfxBatchKey.of(material, mixed = false, owner = draw) + if (glow) VfxMaterialStates.applyGlow(key) else VfxMaterialStates.apply(key, premultiplied = false) + view.drawImmediate(mesh, shader) { bound -> + VfxMaterialStates.bindCommonSamplers(bound, texture) + bound.safeGetUniform("BlendMode").set(VfxQuadPacker.blendMode(material.blend)) + bound.safeGetUniform("Softness").set(material.softness) + bound.safeGetUniform("Glow").set(glowAmount) + bound.safeGetUniform("GlowPass").set(if (glow) 1f else 0f) + if (bound !== engine) draw.uniforms?.apply(bound) + } + } + VfxMaterialStates.restore() + } + + private fun quads(builder: BufferBuilder, draw: VfxRibbonDraw, first: Int, count: Int, view: VfxView) { + val points = draw.points + val region = draw.material.uv + val stride = VfxRibbonDraw.STRIDE + for (index in first until first + count - 1) { + val at = index * stride + val next = at + stride + sideOf(points, index, first, count, view) + val ax = side[0] + val ay = side[1] + val az = side[2] + sideOf(points, index + 1, first, count, view) + + val v0 = if (draw.repeat) points[at + 8] else region.v0 + points[at + 8] * region.height + val v1 = if (draw.repeat) points[next + 8] else region.v0 + points[next + 8] * region.height + vertex(builder, points, at, -ax, -ay, -az, region.u0, v0) + vertex(builder, points, at, ax, ay, az, region.u1, v0) + vertex(builder, points, next, side[0], side[1], side[2], region.u1, v1) + vertex(builder, points, next, -side[0], -side[1], -side[2], region.u0, v1) + } + } + + /** The half-width vector at a point: across the ribbon and across the line to the eye. */ + private fun sideOf(points: FloatArray, index: Int, first: Int, count: Int, view: VfxView) { + val stride = VfxRibbonDraw.STRIDE + val before = (index - 1).coerceAtLeast(first) * stride + val after = (index + 1).coerceAtMost(first + count - 1) * stride + val tx = points[after] - points[before] + val ty = points[after + 1] - points[before + 1] + val tz = points[after + 2] - points[before + 2] + + val at = index * stride + val ex = view.eye.x - points[at] + val ey = view.eye.y - points[at + 1] + val ez = view.eye.z - points[at + 2] + + var sx = ty * ez - tz * ey + var sy = tz * ex - tx * ez + var sz = tx * ey - ty * ex + val length = sqrt(sx * sx + sy * sy + sz * sz) + val half = points[at + 3] + if (length < 1.0e-6f) { + sx = view.right.x; sy = view.right.y; sz = view.right.z + } else { + sx /= length; sy /= length; sz /= length + } + side[0] = sx * half + side[1] = sy * half + side[2] = sz * half + } + + private fun vertex( + builder: BufferBuilder, + points: FloatArray, + at: Int, + dx: Float, + dy: Float, + dz: Float, + u: Float, + v: Float, + ) { + builder.addVertex(points[at] + dx, points[at + 1] + dy, points[at + 2] + dz) + .setUv(u, v) + .setColor(points[at + 4], points[at + 5], points[at + 6], points[at + 7]) + .setLight(points[at + 9].toRawBits()) + } + + private fun depth(draw: VfxRibbonDraw, view: VfxView): Float { + val points = draw.points + if (points.isEmpty()) return 0f + val dx = points[0] - view.eye.x + val dy = points[1] - view.eye.y + val dz = points[2] - view.eye.z + return dx * dx + dy * dy + dz * dz + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRibbons.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRibbons.kt new file mode 100644 index 000000000..ccd585f68 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxRibbons.kt @@ -0,0 +1,429 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import org.joml.Matrix4f +import ru.hollowhorizon.hollowengine.client.utils.math.asMatrix4f +import ru.hollowhorizon.hollowengine.client.vfx.VfxColorSampler +import ru.hollowhorizon.hollowengine.client.vfx.VfxEvalContext +import ru.hollowhorizon.hollowengine.client.vfx.VfxFloatSampler +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeBehavior +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeRuntime +import ru.hollowhorizon.hollowengine.client.vfx.VfxNoise +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticleLook +import ru.hollowhorizon.hollowengine.client.vfx.VfxRangeMode +import ru.hollowhorizon.hollowengine.client.vfx.VfxSamplers +import ru.hollowhorizon.hollowengine.client.vfx.VfxVec3Sampler +import ru.hollowhorizon.hollowengine.common.utils.MutableColor +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxBeamSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxMaterialSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxRibbonUv +import ru.hollowhorizon.hollowengine.common.vfx.VfxSurfaceSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTrailSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Collects the strips of one ribbon node for one frame, in the space of the view. + */ +internal class VfxRibbonBuilder { + private var points = FloatArray(VfxRibbonDraw.STRIDE * 64) + private var count = 0 + private var strips = IntArray(16) + private var stripCount = 0 + private var stripStart = 0 + + fun begin() { + count = 0 + stripCount = 0 + } + + fun startStrip() { + stripStart = count + } + + fun point(x: Float, y: Float, z: Float, halfWidth: Float, color: MutableColor, along: Float, light: Int) { + val stride = VfxRibbonDraw.STRIDE + if (points.size < (count + 1) * stride) points = points.copyOf(points.size * 2) + val at = count * stride + points[at] = x + points[at + 1] = y + points[at + 2] = z + points[at + 3] = halfWidth + points[at + 4] = color.r + points[at + 5] = color.g + points[at + 6] = color.b + points[at + 7] = color.a + points[at + 8] = along + points[at + 9] = Float.fromBits(light) + count++ + } + + /** Closes the strip; one with less than two points is dropped. [scale] multiplies what [point] got as `along`. */ + fun endStrip(scale: Float = 1f, shift: Float = 0f) { + val length = count - stripStart + if (length < 2) { + count = stripStart + return + } + for (index in stripStart until count) { + val at = index * VfxRibbonDraw.STRIDE + 8 + points[at] = points[at] * scale + shift + } + if (strips.size < (stripCount + 1) * 2) strips = strips.copyOf(strips.size * 2) + strips[stripCount * 2] = stripStart + strips[stripCount * 2 + 1] = length + stripCount++ + } + + fun build(material: VfxMaterialSpec, uniforms: VfxUniformValues?, repeat: Boolean): VfxRibbonDraw? { + if (stripCount == 0) return null + return VfxRibbonDraw( + material = material, + uniforms = uniforms, + points = points.copyOf(count * VfxRibbonDraw.STRIDE), + strips = strips.copyOf(stripCount * 2), + stripCount = stripCount, + repeat = repeat, + ) + } +} + +/** What a trail and a beam share: width and tint read along their length, and the material. */ +abstract class VfxRibbonNode(spec: VfxSurfaceSpec, protected val node: VfxNodeRuntime) : VfxNodeBehavior { + private val uniforms = spec.material.shader?.let { shader -> + VfxUniformBinding(shader, spec.material.uniforms, spec.material.samplers, node) + } + + protected val tint: VfxColorSampler = VfxSamplers.color( + spec.tint, + node.expressions, + salt = VfxProperty.TINT.hashCode(), + drive = node.drive(VfxProperty.TINT), + ) + + /** The inputs of the point being read; `p.progress` is how far along the ribbon it is. */ + protected val pointContext = VfxEvalContext() + protected val color = MutableColor(1f, 1f, 1f, 1f) + internal val builder = VfxRibbonBuilder() + + protected fun scalar(property: VfxProperty, value: VfxValue, default: Float): VfxFloatSampler = + VfxSamplers.driven( + VfxSamplers.scalar(value, node.expressions, default, VfxRangeMode.PER_PARTICLE, property.hashCode()), + node.drive(property), + ) + + protected fun uniforms(): VfxUniformValues? = uniforms?.evaluate(node.context) + + /** How much [matrix] scales lengths, for widths given in the space of the ribbon. */ + protected fun scaleOf(matrix: Matrix4f): Float = + sqrt(matrix.m00() * matrix.m00() + matrix.m01() * matrix.m01() + matrix.m02() * matrix.m02()) +} + +/** + * The trail of the node itself, or of every particle of the emitter it sits under. + */ +class VfxTrailRenderer(private val spec: VfxTrailSpec, node: VfxNodeRuntime) : VfxRibbonNode(spec, node) { + private val source = node.particleSource + private val maxPoints = spec.maxPoints.coerceIn(2, MAX_POINTS) + private val lifetime = spec.lifetime.coerceAtLeast(MIN_LIFETIME) + private val minDistanceSquared = spec.minDistance * spec.minDistance + private val width = scalar(VfxProperty.WIDTH, spec.width, 0.2f) + + private val own = FloatArray(maxPoints * 4) + private var ownCount = 0 + + private val points: FloatArray? = source?.particles?.channel("trail:${spec.id}:points", maxPoints * 4) + + /** The color this trail gives each particle; only the color, a trail has a width of its own. */ + private val look: FloatArray? = source?.let { emitter -> + VfxParticleLook(spec.particle, node, emitter.particles).also(emitter::addLook).data + } + + /** Per particle: how many points, and the index and random of the particle they were taken for. */ + private val meta: FloatArray? = source?.particles?.channel("trail:${spec.id}:meta", 3) + + private val head = MutableVec3f() + private val scratch = Matrix4f() + + override val isIdle: Boolean get() = source != null || ownCount == 0 + + override fun restart() { + ownCount = 0 + } + + override fun stop(immediate: Boolean) { + if (immediate) ownCount = 0 + } + + override fun update(dt: Float) { + val emitter = source + if (emitter == null) { + ownCount = age(own, 0, ownCount, dt) + if (node.isActive) { + val offset = node.instance.originMinusAnchor + val position = node.frame.position + ownCount = record(own, 0, ownCount, position.x + offset.x, position.y + offset.y, position.z + offset.z) + } + return + } + + val particles = emitter.particles + val points = points ?: return + val meta = meta ?: return + for (slot in 0 until particles.count) { + val at = slot * 3 + if (meta[at + 1] != particles.index[slot] || meta[at + 2] != particles.random[slot]) { + meta[at] = 0f + meta[at + 1] = particles.index[slot] + meta[at + 2] = particles.random[slot] + } + val base = slot * maxPoints * 4 + var count = age(points, base, meta[at].toInt(), dt) + val x = particles.positionX[slot] + val y = particles.positionY[slot] + val z = particles.positionZ[slot] + count = record(points, base, count, x, y, z) + meta[at] = count.toFloat() + } + } + + /** Ages every point and drops the ones past their lifetime; returns how many are left. */ + private fun age(data: FloatArray, base: Int, count: Int, dt: Float): Int { + for (index in 0 until count) data[base + index * 4 + 3] += dt + var left = count + while (left > 0 && data[base + (left - 1) * 4 + 3] > lifetime) left-- + return left + } + + /** Takes the head as a new point once it has moved far enough from the newest one. */ + private fun record(data: FloatArray, base: Int, count: Int, x: Float, y: Float, z: Float): Int { + if (count > 0) { + val dx = x - data[base] + val dy = y - data[base + 1] + val dz = z - data[base + 2] + if (dx * dx + dy * dy + dz * dz < minDistanceSquared) return count + } + val kept = count.coerceAtMost(maxPoints - 1) + System.arraycopy(data, base, data, base + 4, kept * 4) + data[base] = x + data[base + 1] = y + data[base + 2] = z + data[base + 3] = 0f + return kept + 1 + } + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + if (!node.isActive) return + builder.begin() + + val emitter = source + if (emitter == null) { + val offset = node.instance.originMinusAnchor + val matrix = scratch.set(placement).translate(-offset.x, -offset.y, -offset.z) + val position = node.frame.position + head.set(position.x + offset.x, position.y + offset.y, position.z + offset.z) + pointContext.copyInputs(node.context) + strip(matrix, own, 0, ownCount, 1f, 1f, 1f, 1f, node.instance.lightAt(position)) + } else { + val particles = emitter.particles + val points = points ?: return + val meta = meta ?: return + val look = look ?: return + val matrix = scratch.set(placement).mul(emitter.simToRender.asMatrix4f()) + for (slot in 0 until particles.count) { + head.set(particles.positionX[slot], particles.positionY[slot], particles.positionZ[slot]) + emitter.fillParticleContext(slot) + pointContext.copyInputs(emitter.context) + val tone = slot * VfxParticleLook.STRIDE + VfxParticleLook.COLOR + strip( + matrix, points, slot * maxPoints * 4, meta[slot * 3].toInt(), + look[tone], look[tone + 1], look[tone + 2], look[tone + 3], + particles.light[slot], + ) + } + } + + builder.build(spec.material, uniforms(), repeat = spec.uvMode == VfxRibbonUv.TILE)?.let(into.ribbons::add) + } + + private fun strip( + matrix: Matrix4f, + data: FloatArray, + base: Int, + count: Int, + red: Float, + green: Float, + blue: Float, + alpha: Float, + light: Int, + ) { + val scale = scaleOf(matrix) + builder.startStrip() + addPoint(matrix, head.x, head.y, head.z, 0f, 0f, scale, red, green, blue, alpha, light) + + var along = 0f + var previousX = head.x + var previousY = head.y + var previousZ = head.z + for (index in 0 until count) { + val at = base + index * 4 + val dx = data[at] - previousX + val dy = data[at + 1] - previousY + val dz = data[at + 2] - previousZ + val step = sqrt(dx * dx + dy * dy + dz * dz) + if (step < 1.0e-5f) continue + along += step + addPoint( + matrix, data[at], data[at + 1], data[at + 2], data[at + 3], along, scale, + red, green, blue, alpha, light, + ) + previousX = data[at] + previousY = data[at + 1] + previousZ = data[at + 2] + } + + when (spec.uvMode) { + VfxRibbonUv.STRETCH -> builder.endStrip(if (along > 0f) 1f / along else 0f) + VfxRibbonUv.TILE -> builder.endStrip(1f / spec.tileLength.coerceAtLeast(MIN_TILE)) + } + } + + private fun addPoint( + matrix: Matrix4f, + x: Float, + y: Float, + z: Float, + age: Float, + along: Float, + scale: Float, + red: Float, + green: Float, + blue: Float, + alpha: Float, + light: Int, + ) { + pointContext.progress = (age / lifetime).coerceIn(0f, 1f) + val halfWidth = width.eval(pointContext) * scale * 0.5f + tint.eval(pointContext, color) + color.set(color.r * red, color.g * green, color.b * blue, color.a * alpha) + builder.point( + matrix.m00() * x + matrix.m10() * y + matrix.m20() * z + matrix.m30(), + matrix.m01() * x + matrix.m11() * y + matrix.m21() * z + matrix.m31(), + matrix.m02() * x + matrix.m12() * y + matrix.m22() * z + matrix.m32(), + halfWidth, color, along, light, + ) + } + + private companion object { + const val MAX_POINTS = 256 + const val MIN_LIFETIME = 0.01f + const val MIN_TILE = 0.01f + } +} + +/** + * A ribbon from the node to its target, bent sideways by noise that leaves both ends in place. + */ +class VfxBeamRenderer(private val spec: VfxBeamSpec, node: VfxNodeRuntime) : VfxRibbonNode(spec, node) { + private val width = scalar(VfxProperty.WIDTH, spec.width, 0.15f) + private val noise = scalar(VfxProperty.BEAM_NOISE, spec.noise, 0f) + private val end: VfxVec3Sampler = VfxSamplers.vec3( + VfxVec3Value.of(spec.end.x, spec.end.y, spec.end.z), + node.expressions, + drive = node.drive(VfxProperty.BEAM_END), + ) + + private val target: VfxNodeRuntime? by lazy { spec.target.takeIf { it.isNotBlank() }?.let(node.instance::node) } + + private val start = MutableVec3f() + private val finish = MutableVec3f() + private val scratch = MutableVec3f() + private val side = MutableVec3f() + private val lift = MutableVec3f() + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + if (!node.isActive) return + + start.set(node.frame.position) + val targetNode = target + if (targetNode != null) { + finish.set(targetNode.frame.position) + } else { + end.eval(node.context, scratch) + node.frame.transformPoint(Vec3f(scratch.x, scratch.y, scratch.z), finish) + } + + val dx = finish.x - start.x + val dy = finish.y - start.y + val dz = finish.z - start.z + val length = sqrt(dx * dx + dy * dy + dz * dz) + if (length < 1.0e-4f) return + perpendiculars(dx / length, dy / length, dz / length) + + pointContext.copyInputs(node.context) + val amplitude = noise.eval(node.context) + val segments = spec.segments.coerceIn(1, MAX_SEGMENTS) + val time = node.instance.time + val light = node.instance.lightAt(node.frame.position) + val scale = scaleOf(placement) + + builder.begin() + builder.startStrip() + for (index in 0..segments) { + val t = index.toFloat() / segments + var x = start.x + dx * t + var y = start.y + dy * t + var z = start.z + dz * t + if (amplitude != 0f) { + val envelope = sin(PI.toFloat() * t) * amplitude + val phase = t * length * spec.noiseFrequency + val wave = time * spec.noiseSpeed + val sideways = VfxNoise.sample(phase, wave, 0f, node.seed) * envelope + val upward = VfxNoise.sample(phase, wave, 0f, node.seed + 1) * envelope + x += side.x * sideways + lift.x * upward + y += side.y * sideways + lift.y * upward + z += side.z * sideways + lift.z * upward + } + + pointContext.progress = t + tint.eval(pointContext, color) + builder.point( + placement.m00() * x + placement.m10() * y + placement.m20() * z + placement.m30(), + placement.m01() * x + placement.m11() * y + placement.m21() * z + placement.m31(), + placement.m02() * x + placement.m12() * y + placement.m22() * z + placement.m32(), + width.eval(pointContext) * scale * 0.5f, + color, + t, + light, + ) + } + + val tiles = when (spec.uvMode) { + VfxRibbonUv.STRETCH -> 1f + VfxRibbonUv.TILE -> length / spec.tileLength.coerceAtLeast(0.01f) + } + builder.endStrip(tiles, -time * spec.uvScroll) + + val repeat = spec.uvMode == VfxRibbonUv.TILE || spec.uvScroll != 0f + builder.build(spec.material, uniforms(), repeat)?.let(into.ribbons::add) + } + + /** Two directions across the beam, for the noise to push along: off world up, or off X when the beam is upright. */ + private fun perpendiculars(x: Float, y: Float, z: Float) { + val upright = abs(y) > 0.95f + val upX = if (upright) 1f else 0f + val upY = if (upright) 0f else 1f + side.set(-z * upY, z * upX, x * upY - y * upX).norm() + lift.set(y * side.z - z * side.y, z * side.x - x * side.z, x * side.y - y * side.x) + } + + private companion object { + const val MAX_SEGMENTS = 256 + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxScreenNodes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxScreenNodes.kt new file mode 100644 index 000000000..002f8b203 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxScreenNodes.kt @@ -0,0 +1,158 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.pipeline.RenderTarget +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.DefaultVertexFormat +import com.mojang.blaze3d.vertex.Tesselator +import com.mojang.blaze3d.vertex.VertexFormat +import org.joml.Matrix4f +import org.joml.Vector3f +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeBehavior +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeRuntime +import ru.hollowhorizon.hollowengine.client.vfx.VfxNoise +import ru.hollowhorizon.hollowengine.client.vfx.VfxRangeMode +import ru.hollowhorizon.hollowengine.client.vfx.VfxSamplers +import ru.hollowhorizon.hollowengine.common.vfx.VfxCameraShakeSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPostEffectSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxSkySpec + +/** Hands the frame its full-screen pass while the node is on. */ +class VfxPostEffectNode(private val spec: VfxPostEffectSpec, private val node: VfxNodeRuntime) : VfxNodeBehavior { + private val uniforms = VfxUniformBinding(spec.shader, spec.uniforms, spec.samplers, node) + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + if (!node.isActive || spec.shader.isBlank()) return + into.posts += VfxPostDraw(spec.shader, uniforms.evaluate(node.context)) + } +} + +/** Hands the frame its sky pass while the node is on. */ +class VfxSkyNode(private val spec: VfxSkySpec, private val node: VfxNodeRuntime) : VfxNodeBehavior { + private val uniforms = VfxUniformBinding(spec.shader, spec.uniforms, spec.samplers, node) + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + if (!node.isActive || spec.shader.isBlank()) return + val origin = node.frame.position + into.skies += VfxSkyDraw( + shader = spec.shader, + uniforms = uniforms.evaluate(node.context), + position = placement.transformPosition(Vector3f(origin.x, origin.y, origin.z)), + time = node.context.effectTime, + ) + } +} + +/** + * Adds its swing to the camera shake of the frame. The swing follows smooth noise, one channel per + * axis, so it never repeats and never jumps. + */ +class VfxCameraShake(private val spec: VfxCameraShakeSpec, private val node: VfxNodeRuntime) : VfxNodeBehavior { + private val strength = VfxSamplers.driven( + VfxSamplers.scalar(spec.strength, node.expressions, 1f, VfxRangeMode.PER_PARTICLE), + node.drive(VfxProperty.SHAKE_STRENGTH), + ) + private val frequency = VfxSamplers.driven( + VfxSamplers.scalar(spec.frequency, node.expressions, 14f, VfxRangeMode.PER_PARTICLE), + node.drive(VfxProperty.SHAKE_FREQUENCY), + ) + + /** How far through the noise the shake has run; frequency changes speed it up, never jump it. */ + private var phase = 0f + + override fun update(dt: Float) { + if (dt > 0f) phase += frequency.eval(node.context).coerceAtLeast(0f) * dt + } + + override fun restart() { + phase = 0f + } + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + if (!node.isActive) return + val scale = strength.eval(node.context).coerceAtLeast(0f) + if (scale == 0f) return + val amplitude = spec.amplitude + into.shake[0] += VfxNoise.sample(phase, 0f, 0f, node.seed) * amplitude.x * scale + into.shake[1] += VfxNoise.sample(phase, AXIS_OFFSET, 0f, node.seed) * amplitude.y * scale + into.shake[2] += VfxNoise.sample(phase, AXIS_OFFSET * 2f, 0f, node.seed) * amplitude.z * scale + } + + private companion object { + /** Far enough apart in the noise that the axes do not move together. */ + const val AXIS_OFFSET = 37.3f + } +} + +/** + * Draws the sky nodes of the frame, each a quad over the whole screen on the far plane. + */ +object VfxSkyRenderer { + fun render(skies: List, view: VfxView) { + if (skies.isEmpty()) return + val toView = Matrix4f(view.projection).mul(view.modelView).invert() + + RenderSystem.enableDepthTest() + RenderSystem.depthFunc(GL33.GL_LEQUAL) + RenderSystem.depthMask(false) + RenderSystem.enableBlend() + RenderSystem.defaultBlendFunc() + RenderSystem.disableCull() + try { + skies.forEach { sky -> + val shader = VfxShaders.get(sky.shader, DefaultVertexFormat.POSITION) ?: return@forEach + val builder = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION) + builder.addVertex(-1f, -1f, 1f) + builder.addVertex(1f, -1f, 1f) + builder.addVertex(1f, 1f, 1f) + builder.addVertex(-1f, 1f, 1f) + val mesh = builder.build() ?: return@forEach + + val offset = Vector3f(sky.position).sub(view.eye) + val center = if (offset.lengthSquared() > 1e-6f) Vector3f(offset).normalize() else Vector3f(0f, 1f, 0f) + view.drawImmediate(mesh, shader) { bound -> + bound.safeGetUniform("InvViewProjMat").set(toView) + bound.safeGetUniform("SkyCenter").set(center.x, center.y, center.z) + bound.safeGetUniform("NodeOffset").set(offset.x, offset.y, offset.z) + bound.safeGetUniform("EffectTime").set(sky.time) + sky.uniforms.apply(bound) + } + } + } finally { + VfxMaterialStates.restore() + } + } +} + +/** + * Draws the full-screen passes of the frame, each over the result of the one before it. + */ +object VfxPostProcessor { + fun apply(posts: List, target: RenderTarget) { + if (posts.isEmpty()) return + + RenderSystem.disableDepthTest() + RenderSystem.depthMask(false) + RenderSystem.disableBlend() + RenderSystem.disableCull() + try { + posts.forEach { post -> + val shader = VfxShaders.get(post.shader, DefaultVertexFormat.POSITION_TEX) ?: return@forEach + VfxSceneTextures.capture(target) + + VfxScreenQuad.draw(shader) { bound -> + VfxSceneTextures.bind(bound) + bound.safeGetUniform("ScreenSize").set(target.width.toFloat(), target.height.toFloat()) + post.uniforms.apply(bound) + } + } + } finally { + RenderSystem.enableDepthTest() + RenderSystem.depthMask(true) + RenderSystem.enableBlend() + RenderSystem.defaultBlendFunc() + RenderSystem.enableCull() + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxShaderDeclarations.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxShaderDeclarations.kt new file mode 100644 index 000000000..8c4dabde0 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxShaderDeclarations.kt @@ -0,0 +1,123 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import net.minecraft.client.Minecraft +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.vfx.VfxColorValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxRgba +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value + +/** A uniform as a shader json declares it. */ +class VfxShaderUniform(val name: String, val type: String, val count: Int, val defaults: FloatArray) { + val isInt: Boolean get() = type.startsWith("int") + + /** The authored value the json default stands for: a number, a vector up to three, a color of four. */ + fun defaultValue(): VfxUniformValue = when { + count >= 4 -> VfxUniformValue.Color(VfxColorValue.Solid(VfxRgba(at(0), at(1), at(2), at(3)))) + count >= 2 -> VfxUniformValue.Vector(VfxVec3Value.of(at(0), at(1), at(2))) + else -> VfxUniformValue.Scalar(VfxValue.of(at(0))) + } + + /** Whether [value] has the shape this uniform takes. */ + fun accepts(value: VfxUniformValue): Boolean = value.kind == defaultValue().kind + + private fun at(index: Int) = defaults.getOrElse(index) { 0f } +} + +/** + * What a core shader json declares, minus what the engine fills in itself: the uniforms and the + * samplers an effect author gives values to. + */ +class VfxShaderDeclaration( + val uniforms: List, + val samplers: List, + /** Whether the shader declares `GlowPass`, and so draws the glow of its surface itself. */ + val drawsGlow: Boolean = false, + /** What a uniform or sampler is called in the GLSL, where that is not its own name. */ + private val glslNames: Map = emptyMap(), + /** The texture a sampler shows until the effect names one. */ + val samplerDefaults: Map = emptyMap(), +) { + fun uniform(name: String): VfxShaderUniform? = uniforms.firstOrNull { it.name == name } + + /** What [name] is called in the GLSL: a material graph names its properties `p_` there. */ + fun glslName(name: String): String = glslNames[name] ?: name + + /** + * The uniforms of [authored] brought in line with this shader: one per declared uniform, the + * authored value where it has the right shape and the json default everywhere else. + */ + fun align(authored: List): List = uniforms.map { uniform -> + val kept = authored.firstOrNull { it.name == uniform.name && uniform.accepts(it.value) } + kept ?: VfxUniformSpec(uniform.name, uniform.defaultValue()) + } + + companion object { + /** Set by the render system or by the effect renderers for every draw. */ + private val ENGINE_UNIFORMS = setOf( + "ModelViewMat", "ProjMat", "IViewRotMat", "TextureMat", "ColorModulator", "Light0_Direction", + "Light1_Direction", "FogStart", "FogEnd", "FogColor", "FogShape", "LineWidth", "GameTime", + "ScreenSize", "GlintAlpha", "ChunkOffset", "Shaded", "BlendMode", "SkyCenter", "NodeOffset", "EffectTime", + "Softness", "Glow", "GlowPass", + ) + + /** The material texture, the light map and the scene copies. */ + private val ENGINE_SAMPLERS = setOf("Sampler0", "Sampler1", "Sampler2", "SceneColor", "SceneDepth") + + fun parse(json: JsonObject): VfxShaderDeclaration { + val uniforms = json.getAsJsonArray("uniforms")?.mapNotNull { element -> + val uniform = element.asJsonObject + val name = uniform.get("name")?.asString ?: return@mapNotNull null + val type = uniform.get("type")?.asString ?: "float" + if (name in ENGINE_UNIFORMS || type.startsWith("matrix")) return@mapNotNull null + val values = uniform.getAsJsonArray("values")?.map { it.asFloat }?.toFloatArray() ?: FloatArray(0) + VfxShaderUniform(name, type, uniform.get("count")?.asInt ?: values.size.coerceAtLeast(1), values) + }.orEmpty() + val samplers = json.getAsJsonArray("samplers")?.mapNotNull { element -> + element.asJsonObject.get("name")?.asString?.takeIf { it !in ENGINE_SAMPLERS } + }.orEmpty() + val drawsGlow = json.getAsJsonArray("uniforms")?.any { it.asJsonObject.get("name")?.asString == "GlowPass" } == true + return VfxShaderDeclaration(uniforms, samplers, drawsGlow) + } + } +} + +/** + * The declarations of the shaders effects name, read from their json once, or for a material graph + * from its properties. The editor lays out its fields from them, and a draw resets what it does not set + * to the defaults, so one node never inherits the uniforms another node left in the same shader. + */ +object VfxShaderDeclarations { + private val read = HashMap() + + /** The declaration of `namespace:path`, or null when there is no such shader json. */ + @Synchronized + fun of(location: String): VfxShaderDeclaration? = read.getOrPut(location) { load(location) } + + @Synchronized + fun clear() = read.clear() + + /** Forgets [location], which changed, so it is read again. */ + @Synchronized + fun invalidate(location: String) { + read.remove(location) + } + + private fun load(location: String): VfxShaderDeclaration? { + if (VfxGraphMaterials.isGraph(location)) return VfxGraphMaterials.read(location)?.let(VfxGraphMaterials::declaration) + val id = ResourceLocation.tryParse(location) ?: return null + val file = ResourceLocation.fromNamespaceAndPath(id.namespace, "shaders/core/${id.path}.json") + val resource = Minecraft.getInstance().resourceManager.getResource(file).orElse(null) ?: return null + return try { + resource.openAsReader().use { VfxShaderDeclaration.parse(JsonParser.parseReader(it).asJsonObject) } + } catch (e: Exception) { + HollowEngine.LOGGER.warn("Could not read the effect shader {}: {}", location, e.message) + null + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxSurfaceNodes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxSurfaceNodes.kt new file mode 100644 index 000000000..14aaf429c --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxSurfaceNodes.kt @@ -0,0 +1,135 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import org.joml.Matrix4f +import ru.hollowhorizon.hollowengine.client.models.internal.v2.ModelAttachment +import ru.hollowhorizon.hollowengine.client.utils.math.asMatrix4f +import ru.hollowhorizon.hollowengine.client.vfx.VfxColorSampler +import ru.hollowhorizon.hollowengine.client.vfx.VfxEmitter +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeBehavior +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeRuntime +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticleLook +import ru.hollowhorizon.hollowengine.client.vfx.VfxParticles +import ru.hollowhorizon.hollowengine.client.vfx.VfxSamplers +import ru.hollowhorizon.hollowengine.common.utils.MutableColor +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.vfx.VfxAppearance +import ru.hollowhorizon.hollowengine.common.vfx.VfxColorValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxMeshSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxModelSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPlaneSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxSurfaceSpec + +/** + * What every renderer node shares: the particles it draws, the look it gives them, and its tint. + * + * Under an emitter those are the emitter's particles, sized and colored by a [VfxParticleLook] of + * this node. Anywhere else the node draws one particle of its own, a unit one at its origin, so the + * same draw path covers both. + */ +abstract class VfxRendererNode( + protected val node: VfxNodeRuntime, + tint: VfxColorValue, + appearance: VfxAppearance, +) : VfxNodeBehavior { + /** The emitter whose particles this node draws, or null when it draws itself. */ + protected val source: VfxEmitter? = node.particleSource + + private val own: VfxParticles? = if (source == null) VfxParticles(1).also(::placeOwnParticle) else null + + private val look: FloatArray = source + ?.let { emitter -> VfxParticleLook(appearance, node, emitter.particles).also(emitter::addLook).data } + ?: VfxParticleLook.unit() + + private val tintSampler: VfxColorSampler = VfxSamplers.color( + tint, + node.expressions, + salt = VfxProperty.TINT.hashCode(), + drive = node.drive(VfxProperty.TINT), + ) + private val tintScratch = MutableColor(1f, 1f, 1f, 1f) + private val frameMatrix = MutableMat4f() + + /** What this node draws this frame, or null when it draws nothing. */ + protected fun batch(placement: Matrix4f): VfxParticleBatch? { + if (!node.isActive) return null + + tintSampler.eval(node.context, tintScratch) + val tint = floatArrayOf(tintScratch.r, tintScratch.g, tintScratch.b, tintScratch.a) + + val emitter = source + if (emitter != null) { + if (emitter.particles.count == 0) return null + val local = node.transform + return VfxParticleBatch( + particles = emitter.particles, + look = look, + matrix = Matrix4f(placement).mul(emitter.simToRender.asMatrix4f()), + uvColumns = emitter.uvColumns, + uvRows = emitter.uvRows, + offset = local.position, + spin = local.rotation, + sizeScale = local.scale, + tint = tint, + ) + } + + val particles = own ?: return null + particles.light[0] = node.instance.lightAt(node.frame.position) + return VfxParticleBatch( + particles = particles, + look = look, + matrix = Matrix4f(placement).mul(node.frame.toMatrix(frameMatrix).asMatrix4f()), + tint = tint, + ) + } + + private fun placeOwnParticle(particles: VfxParticles) { + particles.allocate() + particles.lifetime[0] = 1f + } +} + +/** A renderer node drawn with a material, and the uniforms that material's shader takes. */ +abstract class VfxSurfaceNode(spec: VfxSurfaceSpec, node: VfxNodeRuntime, appearance: VfxAppearance) : + VfxRendererNode(node, spec.tint, appearance) { + private val uniforms = spec.material.shader?.let { shader -> + VfxUniformBinding(shader, spec.material.uniforms, spec.material.samplers, node) + } + + protected fun uniforms(): VfxUniformValues? = uniforms?.evaluate(node.context) +} + +class VfxPlaneNode(private val spec: VfxPlaneSpec, node: VfxNodeRuntime) : VfxSurfaceNode(spec, node, spec.particle) { + override fun collect(into: VfxDrawList, placement: Matrix4f) { + val batch = batch(placement) ?: return + into.quads += VfxQuadDraw(spec, batch, uniforms()) + } +} + +class VfxMeshNode(private val spec: VfxMeshSpec, node: VfxNodeRuntime) : VfxSurfaceNode(spec, node, spec.particle) { + private val primitive = spec.primitive + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + val batch = batch(placement) ?: return + into.meshes += VfxMeshDraw(spec, primitive, batch, uniforms()) + } +} + +/** + * A model drawn through the model renderer. Each particle is submitted as one instance of the model, + * so the model renderer batches them the way it batches entities. + */ +class VfxModelNode(private val spec: VfxModelSpec, node: VfxNodeRuntime) : + VfxRendererNode(node, VfxColorValue.WHITE, spec.particle) { + /** Loaded the first time it is actually drawn. */ + private val model: ModelAttachment? by lazy { + spec.model.takeIf { it.isNotBlank() }?.let { ModelAttachment(it) } + } + + override fun collect(into: VfxDrawList, placement: Matrix4f) { + val batch = batch(placement) ?: return + val attachment = model ?: return + into.models += VfxModelDraw(spec, attachment, batch) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxUniforms.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxUniforms.kt new file mode 100644 index 000000000..bc318cd24 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxUniforms.kt @@ -0,0 +1,132 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.ShaderInstance +import ru.hollowhorizon.hollowengine.client.vfx.VfxColorSampler +import ru.hollowhorizon.hollowengine.client.vfx.VfxEvalContext +import ru.hollowhorizon.hollowengine.client.vfx.VfxFloatSampler +import ru.hollowhorizon.hollowengine.client.vfx.VfxNodeRuntime +import ru.hollowhorizon.hollowengine.client.vfx.VfxRangeMode +import ru.hollowhorizon.hollowengine.client.vfx.VfxSamplers +import ru.hollowhorizon.hollowengine.client.vfx.VfxVec3Sampler +import ru.hollowhorizon.hollowengine.common.utils.MutableColor +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxSamplerSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformValue + +/** + * The uniforms and textures one draw hands its shader, evaluated for this frame. + */ +class VfxUniformValues( + private val shader: String, + private val names: Array, + private val values: FloatArray, + private val samplers: List, +) { + /** + * Sets every uniform the shader declares for the author: to what this draw evaluated, or to the + * default, since the shader still holds whatever the previous draw with it left there. A sampler + * the effect names no texture for shows the default texture of its declaration, if it has one. + */ + fun apply(program: ShaderInstance) { + val declaration = VfxShaderDeclarations.of(shader) + declaration?.uniforms?.forEach { uniform -> + val index = names.indexOf(uniform.name) + val glsl = declaration.glslName(uniform.name) + if (index >= 0) { + set(program, glsl, uniform.isInt, index * 4, values) + } else { + set(program, glsl, uniform.isInt, 0, uniform.defaults.copyOf(4)) + } + } + names.forEachIndexed { index, name -> + if (declaration?.uniform(name) == null) set(program, name, false, index * 4, values) + } + + val textures = Minecraft.getInstance().textureManager + val named = samplers.filter { it.name.isNotBlank() && it.texture.isNotBlank() }.associate { it.name to it.texture } + (declaration?.samplerDefaults.orEmpty() + named).forEach { (name, texture) -> + if (texture.isBlank()) return@forEach + program.setSampler(declaration?.glslName(name) ?: name, textures.getTexture(texture.rl).id) + } + } + + private fun set(program: ShaderInstance, name: String, whole: Boolean, at: Int, from: FloatArray) { + val uniform = program.safeGetUniform(name) + if (whole) { + uniform.setSafe(from[at].toInt(), from[at + 1].toInt(), from[at + 2].toInt(), from[at + 3].toInt()) + } else { + uniform.setSafe(from[at], from[at + 1], from[at + 2], from[at + 3]) + } + } +} + +/** + * Reads what the author wrote into the uniforms of [shader], with the timeline over it, once per + * frame for the node that owns them. + */ +class VfxUniformBinding( + private val shader: String, + uniforms: List, + private val samplers: List, + node: VfxNodeRuntime, +) { + private val names: Array = uniforms.map { it.name }.filter { it.isNotBlank() }.toTypedArray() + + /** Writes one uniform, four floats at [at], for the frame [context] describes. */ + private fun interface Reader { + fun read(context: VfxEvalContext, into: FloatArray, at: Int) + } + + private val readers: List = uniforms.filter { it.name.isNotBlank() }.map { uniform -> reader(uniform, node) } + + fun evaluate(context: VfxEvalContext): VfxUniformValues { + val values = FloatArray(names.size * 4) + readers.forEachIndexed { index, reader -> reader.read(context, values, index * 4) } + return VfxUniformValues(shader, names, values, samplers) + } + + private fun reader(uniform: VfxUniformSpec, node: VfxNodeRuntime): Reader { + val property = VfxProperty.uniform(uniform.name) + val drive = node.drive(property) + val salt = property.hashCode() + val expressions = node.expressions + return when (val value = uniform.value) { + is VfxUniformValue.Scalar -> scalarReader( + VfxSamplers.driven(VfxSamplers.scalar(value.value, expressions, 0f, VfxRangeMode.PER_PARTICLE, salt), drive) + ) + + is VfxUniformValue.Vector -> vectorReader( + VfxSamplers.vec3(value.value, expressions, 0f, VfxRangeMode.PER_PARTICLE, salt, drive) + ) + + is VfxUniformValue.Color -> colorReader(VfxSamplers.color(value.value, expressions, salt, drive)) + } + } + + private fun scalarReader(sampler: VfxFloatSampler) = Reader { context, into, at -> into[at] = sampler.eval(context) } + + private fun vectorReader(sampler: VfxVec3Sampler): Reader { + val scratch = MutableVec3f() + return Reader { context, into, at -> + sampler.eval(context, scratch) + into[at] = scratch.x + into[at + 1] = scratch.y + into[at + 2] = scratch.z + } + } + + private fun colorReader(sampler: VfxColorSampler): Reader { + val scratch = MutableColor(1f, 1f, 1f, 1f) + return Reader { context, into, at -> + sampler.eval(context, scratch) + into[at] = scratch.r + into[at + 1] = scratch.g + into[at + 2] = scratch.b + into[at + 3] = scratch.a + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxWorldRenderer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxWorldRenderer.kt new file mode 100644 index 000000000..02ed022eb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/vfx/render/VfxWorldRenderer.kt @@ -0,0 +1,107 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import com.mojang.blaze3d.systems.RenderSystem +import net.minecraft.client.Minecraft +import org.joml.Matrix4f +import org.lwjgl.opengl.GL33 +import ru.hollowhorizon.hollowengine.client.render.CameraSetupEvent +import ru.hollowhorizon.hollowengine.client.utils.shouldOverrideShaders +import ru.hollowhorizon.hollowengine.client.vfx.VfxBoneBindings +import ru.hollowhorizon.hollowengine.client.vfx.VfxInstance +import ru.hollowhorizon.hollowengine.client.vfx.VfxScenes +import ru.hollowhorizon.hollowengine.common.events.ClientOnly +import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderLevelStageEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderStage + +/** + * Ticks and draws the effects of the world. + */ +@ClientOnly +object VfxWorldRenderer { + private val frame = VfxDrawList() + private val shake = FloatArray(3) + + /** The view of the frame, while its surfaces wait for the shader pack to finish. */ + private var deferred: VfxView? = null + + @SubscribeEvent + fun onRenderLevel(event: RenderLevelStageEvent) { + if (event.stage == RenderStage.AFTER_WEATHER) drawEffects(event) + } + + /** Before the overlays of the level, so that a gizmo or a selection outline stays readable. */ + @SubscribeEvent(10) + fun onLevelDone(event: RenderLevelStageEvent) { + if (event.stage != RenderStage.AFTER_LEVEL || deferred != null || frame.posts.isEmpty()) return + VfxPostProcessor.apply(frame.posts, Minecraft.getInstance().mainRenderTarget) + frame.posts.clear() + } + + /** The shader pack has written its final image; the surfaces that waited for it go on top. */ + fun onShaderPackFrameFinished() { + val view = deferred ?: return + deferred = null + + val main = Minecraft.getInstance().mainRenderTarget + main.bindWrite(true) + VfxFrameRenderer.renderSurfaces(frame, view, main) + VfxPostProcessor.apply(frame.posts, main) + frame.posts.clear() + } + + @SubscribeEvent + fun onCameraSetup(event: CameraSetupEvent) { + event.pitch += shake[0] + event.yaw += shake[1] + event.roll += shake[2] + } + + private fun drawEffects(event: RenderLevelStageEvent) { + val camera = event.camera.position + val scene = VfxScenes.current() + val bones = VfxBoneBindings.drain(camera) + frame.clear() + deferred = null + if (scene == null && bones.isEmpty()) { + shake.fill(0f) + return + } + + scene?.update(camera) + + val levelPose = event.poseStack.last().pose() + scene?.forEachInstance { instance, _ -> + instance.collect(frame, placeInWorld(instance, levelPose, camera.x, camera.y, camera.z)) + } + bones.forEach { binding -> binding.instance.collect(frame, binding.placement) } + frame.shake.copyInto(shake) + if (frame.isEmpty) return + + val view = VfxView.ofCamera(RenderSystem.getModelViewMatrix(), RenderSystem.getProjectionMatrix()) + val main = Minecraft.getInstance().mainRenderTarget + val depthWrite = GL33.glGetBoolean(GL33.GL_DEPTH_WRITEMASK) + try { + if (shouldOverrideShaders()) { + VfxFrameRenderer.renderModels(frame.models) + deferred = view + } else { + VfxFrameRenderer.render(frame, view, main) + } + } finally { + RenderSystem.depthMask(depthWrite) + } + } + + private fun placeInWorld( + instance: VfxInstance, + levelPose: Matrix4f, + cameraX: Double, + cameraY: Double, + cameraZ: Double, + ): Matrix4f = Matrix4f(levelPose).translate( + (instance.origin.x - cameraX).toFloat(), + (instance.origin.y - cameraY).toFloat(), + (instance.origin.z - cameraZ).toFloat(), + ) +} 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 c6bbdae65..7ed0c84a6 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 @@ -7,9 +7,6 @@ import com.mojang.brigadier.arguments.StringArgumentType import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.mojang.brigadier.builder.RequiredArgumentBuilder import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import net.minecraft.client.Minecraft import net.minecraft.commands.CommandSourceStack @@ -23,13 +20,12 @@ import net.minecraft.world.entity.player.Player import net.minecraft.world.phys.Vec3 import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.api.system -import ru.hollowhorizon.hollowengine.client.models.internal.Model -import ru.hollowhorizon.hollowengine.client.models.internal.manager.HollowModelManager import ru.hollowhorizon.hollowengine.client.particles.BedrockParticles import ru.hollowhorizon.hollowengine.client.particles.ParticleEffect import ru.hollowhorizon.hollowengine.client.particles.Transform import ru.hollowhorizon.hollowengine.client.utils.mc -import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope +import ru.hollowhorizon.hollowengine.client.vfx.VfxAssets +import ru.hollowhorizon.hollowengine.common.vfx.Vfx import ru.hollowhorizon.hollowengine.common.coroutines.runtimeContext import ru.hollowhorizon.hollowengine.common.dialogue.DialogueInput import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent @@ -117,24 +113,54 @@ private fun CommandExtension.registerParticleCommands() { SUCCESS } } + + registerVfxCommands() } -private fun CommandExtension.registerModelCommands() { - "model"(arg("model", StringArgumentType.string()) { getAvailableModels() }) { +private fun CommandExtension.registerVfxCommands() { + "vfx"( + arg("pos", Vec3Argument.vec3()), + arg("name", StringArgumentType.greedyString()) { knownEffects() } + ) { executes { - val modelName = StringArgumentType.getString(this, "model") - ShowModelInfoPacket(modelName).send(source.playerOrException) + Vfx.play( + source.level, + Vec3Argument.getVec3(this, "pos"), + StringArgumentType.getString(this, "name"), + ) SUCCESS } } + "vfx"( + arg("entity", EntityArgument.entity()), + arg("name", StringArgumentType.greedyString()) { knownEffects() } + ) { + executes { + Vfx.play( + EntityArgument.getEntity(this, "entity"), + StringArgumentType.getString(this, "name"), + ) + SUCCESS + } + } +} + +private fun knownEffects(): List = + if (isPhysicalClient) VfxAssets.ids.map { it.toString() } else emptyList() + +private fun CommandExtension.registerModelCommands() { + "model"(arg("model", StringArgumentType.string()) { getAvailableModels() }) { + executes { showModelInfo(source, StringArgumentType.getString(this, "model")) } + } + "model" { "info"(arg("model", StringArgumentType.string()) { getAvailableModels() }) { - executes { - val modelName = StringArgumentType.getString(this, "model") - ShowModelInfoPacket(modelName).send(source.playerOrException) - SUCCESS - } + executes { showModelInfo(source, StringArgumentType.getString(this, "model")) } + } + + "entity"(arg("entity", EntityArgument.entity())) { + executes { showEntityModel(source, EntityArgument.getEntity(this, "entity")) } } "attach"( @@ -620,6 +646,10 @@ private fun playEntityAnimation( source.sendFailure("Animation name must not be blank".literal) return 0 } + missingAnimation(entity, animation)?.let { reason -> + source.sendFailure(reason) + return 0 + } NpcAnimationRuntime.apply( entity = entity, @@ -725,42 +755,6 @@ class CopyTextPacket(val text: String) : HollowPacket { } } -@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) -@Serializable -class ShowModelInfoPacket(val model: String) : HollowPacket { - override fun handle(player: Player) { - val location = model.rl - Minecraft.getInstance().coroutineScope.launch { - val hollowModel = HollowModelManager.getOrCreate(location) - .filter { it !== Model.EMPTY } - .first() - - player.sendSystemMessage( - "hollowengine.commands.model_animations".mcTranslate(model.substringAfterLast('/')) - ) - - hollowModel.animationsByName.keys.forEach { anim -> - player.sendSystemMessage( - ("- ".literal + anim.literal) - .onHoverText("hollowengine.tooltips.copy".mcTranslate) - .onClickCopy(anim) - ) - } - - player.sendSystemMessage( - "hollowengine.commands.model_textures".mcTranslate(model.substringAfterLast('/')) - ) - - hollowModel.materials.map { it.texture.path.removeSuffix(".png") }.forEach { texture -> - player.sendSystemMessage( - ("- ".literal + texture.literal) - .onHoverText("hollowengine.tooltips.copy".mcTranslate) - .onClickCopy(texture) - ) - } - } - } -} // endregion diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/ModelInfo.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/ModelInfo.kt new file mode 100644 index 000000000..ac43d3ffe --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/commands/ModelInfo.kt @@ -0,0 +1,58 @@ +package ru.hollowhorizon.hollowengine.common.commands + +import net.minecraft.commands.CommandSourceStack +import net.minecraft.network.chat.Component +import net.minecraft.world.entity.Entity +import ru.hollowhorizon.hollowengine.common.models.ServerModelAnimationMetadata +import ru.hollowhorizon.hollowengine.common.npcs.NpcAnimationRuntime +import ru.hollowhorizon.hollowengine.common.utils.literal +import ru.hollowhorizon.hollowengine.common.utils.mcTranslate +import ru.hollowhorizon.hollowengine.common.utils.onClickCopy +import ru.hollowhorizon.hollowengine.common.utils.onHoverText +import ru.hollowhorizon.hollowengine.common.utils.plus +import java.util.Locale + +/** + * Lists a model's animation clips and materials as command feedback. The server reads the model + * itself, so the answer reaches whoever ran the command, a console or a tool included. + */ +internal fun showModelInfo(source: CommandSourceStack, model: String): Int { + val loaded = ServerModelAnimationMetadata.model(model) + if (loaded == null) { + source.sendFailure("hollowengine.commands.model_unreadable".mcTranslate(model)) + return 0 + } + source.sendSuccess({ "hollowengine.commands.model_animations".mcTranslate(model) }, false) + loaded.animations.sortedBy { it.name }.forEach { clip -> + source.sendSuccess({ entry(clip.name, " (%.2f s)".format(Locale.ROOT, clip.duration)) }, false) + } + source.sendSuccess({ "hollowengine.commands.model_materials".mcTranslate(model) }, false) + loaded.materials.sortedBy { it.name }.forEach { material -> + source.sendSuccess({ entry(material.name, " → ${material.texture}") }, false) + } + return loaded.animations.size +} + +/** [showModelInfo] for the model [entity] shows. */ +internal fun showEntityModel(source: CommandSourceStack, entity: Entity): Int { + val model = NpcAnimationRuntime.modelOf(entity) + if (model == null) { + source.sendFailure("hollowengine.commands.entity_without_model".mcTranslate(entity.name)) + return 0 + } + return showModelInfo(source, model) +} + +/** Refuses a clip [entity]'s model does not have, naming the ones it does. Null when it may play. */ +internal fun missingAnimation(entity: Entity, animation: String): Component? { + val clips = NpcAnimationRuntime.animationNames(entity) ?: return null + if (animation in clips) return null + return "hollowengine.commands.model_no_animation".mcTranslate( + NpcAnimationRuntime.modelOf(entity).orEmpty(), animation, clips.sorted().joinToString(), + ) +} + +private fun entry(name: String, detail: String) = + ("- ".literal + name.literal + detail.literal) + .onHoverText("hollowengine.tooltips.copy".mcTranslate) + .onClickCopy(name) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/Config.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/Config.kt index 87f754667..2eac4186c 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/Config.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/config/Config.kt @@ -87,7 +87,8 @@ open class Config { val parentDir = configFile.parent try { val key = parentDir.register(fileWatcher, ENTRY_MODIFY) - configRegistrations[key] = FileWatcherEntry(this, configFile, configFile.fileName.toString()) + val entry = FileWatcherEntry(this, configFile, configFile.fileName.toString()) + configRegistrations.compute(key) { _, entries -> entries.orEmpty() + (entry.fileName to entry) } } catch (e: Exception) { LOGGER.warn("Failed to register file watcher for {}", configFile.fileName, e) } @@ -179,7 +180,8 @@ open class Config { val CONFIG_DIR: Path = Paths.get("config").toAbsolutePath() private val fileWatcher = FileSystems.getDefault().newWatchService() - private val configRegistrations = ConcurrentHashMap() + /** Watched configs by directory, then by file name: every config in a directory shares its key. */ + private val configRegistrations = ConcurrentHashMap>() private val saveExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> Thread(runnable, "Config-Save-Thread").apply { isDaemon = true @@ -199,12 +201,11 @@ open class Config { while (isActive) { try { val key = fileWatcher.take() ?: continue - val entry = configRegistrations[key] ?: continue + val entries = configRegistrations[key] ?: continue key.pollEvents().forEach { event -> - if (event.kind() == ENTRY_MODIFY && event.context()?.toString() == entry.fileName) { - waitForFileStable(entry) - } + if (event.kind() != ENTRY_MODIFY) return@forEach + entries[event.context()?.toString()]?.let { entry -> waitForFileStable(entry) } } if (!key.reset()) { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/client/render/RenderTickEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/client/render/RenderTickEvent.kt index aa982f3fc..516c7b464 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/client/render/RenderTickEvent.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/client/render/RenderTickEvent.kt @@ -13,6 +13,11 @@ open class RenderTickEvent(val minecraft: Minecraft) : ClientEvent { companion object : EventHandler() } + /** The level is in the main target, finished, and the GUI has not been drawn over it yet. */ + class LevelRendered(minecraft: Minecraft) : RenderTickEvent(minecraft) { + companion object : EventHandler() + } + class Blit(minecraft: Minecraft) : RenderTickEvent(minecraft) { companion object : EventHandler() } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/RigAttachmentTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/RigAttachmentTypes.kt index 56fc5426b..801538364 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/RigAttachmentTypes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/models/RigAttachmentTypes.kt @@ -9,6 +9,7 @@ import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints import ru.hollowhorizon.hollowengine.common.utils.nbt.TagModuleRevision import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.VfxBoneAttachmentSpec import kotlin.reflect.KClass /** @@ -39,6 +40,15 @@ object RigAttachmentTypes { createDefault = { id -> ItemSlotAttachmentSpec(id = id) }, ) ) + register( + RigAttachmentType( + id = "hollowengine:rig/vfx", + specClass = VfxBoneAttachmentSpec::class, + serializer = VfxBoneAttachmentSpec.serializer(), + titleKey = "hollowengine.gui.rig_editor.kind_vfx", + createDefault = { id -> VfxBoneAttachmentSpec(id = id) }, + ) + ) } fun register(type: RigAttachmentType<*>): ExtensionHandle = point.register(type.key, type) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt index 2f9e9e8c6..16c45d7a7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/npcs/NpcAnimationTransitionPacket.kt @@ -6,6 +6,7 @@ import net.minecraft.server.level.ServerLevel import net.minecraft.server.level.ServerPlayer import net.minecraft.world.entity.Entity import net.minecraft.world.entity.player.Player +import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.tick.TickEvent import ru.hollowhorizon.hollowengine.common.attachments.api.AttachmentRegistry @@ -42,9 +43,19 @@ fun onNpcAnimationServerTick(event: TickEvent.Server) { } object NpcAnimationRuntime { + /** Clips already reported missing, so a script playing one in a loop warns once. */ + private val reportedMissing = HashSet>() + + /** The model [entity] shows, when a component says which one. */ + fun modelOf(entity: Entity): String? = + AttachmentRegistry.componentsById(entity).values.filterIsInstance().firstOrNull()?.model + + /** The clip names of [entity]'s model, or null when the entity has no model the server can read. */ + fun animationNames(entity: Entity): Set? = + modelOf(entity)?.let { model -> ServerModelAnimationMetadata.model(model)?.animationsByName?.keys } + fun animationDuration(entity: Entity, animation: String): Float? { - val model = AttachmentRegistry.componentsById(entity).values.filterIsInstance().firstOrNull()?.model - ?: return null + val model = modelOf(entity) ?: return null return ServerModelAnimationMetadata.animationDuration(model, animation) } @@ -62,6 +73,7 @@ object NpcAnimationRuntime { val current = components[animationsId] as? AnimationsComponent ?: AnimationsComponent() val withoutOld = from?.let { current.fadeOutClip(entity.level().gameTime, it, duration) } ?: current val model = components.values.filterIsInstance().firstOrNull()?.model + if (to != null && model != null) warnIfMissing(model, to) val gameTime = entity.level().gameTime val updated = to ?.takeIf(String::isNotBlank) @@ -82,6 +94,16 @@ object NpcAnimationRuntime { components[animationsId] = updated } + /** A clip the model lacks plays as nothing at all; say so, with the names that would have worked. */ + private fun warnIfMissing(model: String, animation: String) { + val clips = ServerModelAnimationMetadata.model(model)?.animationsByName?.keys ?: return + if (animation in clips || !reportedMissing.add(model to animation)) return + HollowEngine.LOGGER.warn( + "Model {} has no animation '{}', so nothing plays. Its animations: {}", + model, animation, clips.sorted().joinToString(), + ) + } + fun removeLayer(entity: Entity, layerId: String) { val animationsId = ComponentDescriptorRegistry.idFor(AnimationsComponent::class) ?: return val components = AttachmentRegistry.componentsById(entity) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/registry/ModShaders.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/registry/ModShaders.kt index cf7ccc224..fbe9151e5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/registry/ModShaders.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/registry/ModShaders.kt @@ -3,6 +3,8 @@ package ru.hollowhorizon.hollowengine.common.registry import com.mojang.blaze3d.vertex.DefaultVertexFormat import net.minecraft.client.renderer.ShaderInstance import ru.hollowhorizon.hollowengine.HollowEngine.MODID +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxMeshRenderer +import ru.hollowhorizon.hollowengine.client.vfx.render.VfxQuadRenderer import ru.hollowhorizon.hollowengine.common.events.ClientOnly import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterShadersEvent @@ -12,6 +14,9 @@ import ru.hollowhorizon.hollowengine.common.utils.rl object ModShaders { lateinit var GLTF_ENTITY: ShaderInstance lateinit var GLTF_ENTITY_INSTANCED: ShaderInstance + var VFX_PARTICLE: ShaderInstance? = null + var VFX_MESH: ShaderInstance? = null + var VFX_RIBBON: ShaderInstance? = null var UI_EFFECT: ShaderInstance? = null var UI_IMAGE_SHADOW: ShaderInstance? = null var MSDF_TEXT: ShaderInstance? = null @@ -30,6 +35,23 @@ object ModShaders { ) { GLTF_ENTITY_INSTANCED = it } + event.register( + "$MODID:vfx_particle".rl, + DefaultVertexFormat.POSITION_TEX_COLOR + ) { + VFX_PARTICLE = it + VfxQuadRenderer.invalidate() + } + event.register( + "$MODID:vfx_mesh".rl, + DefaultVertexFormat.POSITION_TEX + ) { + VFX_MESH = it + VfxMeshRenderer.invalidate() + } + event.register("$MODID:vfx_ribbon".rl, DefaultVertexFormat.PARTICLE) { + VFX_RIBBON = it + } event.register( "$MODID:ui_effect".rl, DefaultVertexFormat.POSITION_TEX_COLOR 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 c9a33432b..e6d980ee5 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 @@ -5,6 +5,7 @@ import net.minecraft.server.MinecraftServer import net.minecraft.world.item.ItemStack import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.scripting.annotations.Import +import ru.hollowhorizon.hollowengine.common.scripting.console.ServerConsoleScript import ru.hollowhorizon.hollowengine.common.scripting.mixins.MixinScript import ru.hollowhorizon.hollowengine.common.scripting.nodes.NodeScript import ru.hollowhorizon.hollowengine.common.scripting.reload.ReloadScript @@ -18,6 +19,7 @@ const val UI_SCRIPT_EXTENSION = "ui.kts" const val RELOAD_SCRIPT_EXTENSION = "reload.kts" const val STARTUP_SCRIPT_EXTENSION = "startup.kts" const val CONSOLE_SCRIPT_EXTENSION = "console.kts" +const val SERVER_CONSOLE_SCRIPT_EXTENSION = "server-console.kts" const val MIXIN_SCRIPT_EXTENSION = "mixin.kts" private const val CLIENT_RELOAD_CONTEXT = "ru.hollowhorizon.hollowengine.client.scripting.ClientReloadContext" private const val CONSOLE_SCRIPT = "ru.hollowhorizon.hollowengine.client.scripting.ConsoleScript" @@ -206,6 +208,26 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.utils.literal", ), ) + this += Provider( + extension = SERVER_CONSOLE_SCRIPT_EXTENSION, + baseClass = ServerConsoleScript::class.qualifiedName!!, + defaultImports = listOf( + Import::class.qualifiedName!!, + ResourceLocation::class.qualifiedName!!, + ItemStack::class.qualifiedName!!, + "net.minecraft.core.BlockPos", + "net.minecraft.world.phys.Vec3", + "net.minecraft.world.entity.Entity", + "net.minecraft.world.entity.LivingEntity", + "net.minecraft.server.level.ServerLevel", + "net.minecraft.server.level.ServerPlayer", + "kotlinx.coroutines.launch", + "kotlinx.coroutines.delay", + "kotlin.time.Duration.Companion.seconds", + "ru.hollowhorizon.hollowengine.common.utils.rl", + "ru.hollowhorizon.hollowengine.common.utils.literal", + ), + ) } } } 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 4790ead4e..d51e52abc 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 @@ -360,11 +360,12 @@ class MemoryClassLoader( override fun getResourceAsStream(name: String): InputStream? = resources[name]?.inputStream() } +/** Kotlin scripting counts lines and columns from 1; [Position] counts them from 0, like the analyzer. */ fun ScriptDiagnostic.convert(): Diagnostic { return Diagnostic(location?.let { Range( - Position(it.start.line, it.start.col), - Position(it.end?.line ?: it.start.line, it.end?.col ?: it.start.col) + Position(it.start.line - 1, it.start.col - 1), + Position((it.end?.line ?: it.start.line) - 1, (it.end?.col ?: it.start.col) - 1) ) } ?: Range(Position(-1, -1), Position(-1, -1)), when (severity) { ScriptDiagnostic.Severity.DEBUG -> Severity.DEBUG diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/console/ServerConsoleScript.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/console/ServerConsoleScript.kt new file mode 100644 index 000000000..a55720a0b --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/console/ServerConsoleScript.kt @@ -0,0 +1,75 @@ +package ru.hollowhorizon.hollowengine.common.scripting.console + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import org.apache.logging.log4j.LogManager +import ru.hollowhorizon.hollowengine.common.coroutines.coroutineScope +import ru.hollowhorizon.hollowengine.common.coroutines.dispatcher +import ru.hollowhorizon.hollowengine.common.events.LogicalSide +import ru.hollowhorizon.hollowengine.common.scripting.SERVER_CONSOLE_SCRIPT_EXTENSION + +/** + * Base of snippets run on the logical server: a dedicated one, or the one inside a singleplayer or LAN + * world. A snippet runs on the server thread, and the coroutines it launches keep running after it + * returns, until [ServerConsoleScripts.stop] or until the server stops. + */ +abstract class ServerConsoleScript(scope: CoroutineScope, val server: MinecraftServer) : CoroutineScope by scope { + val overworld: ServerLevel get() = server.overworld() + + val players: List get() = server.playerList.players + + /** + * The player hosting a singleplayer or LAN world, or the only one on a dedicated server; null when + * that does not name a single player. + */ + val player: ServerPlayer? + get() = players.firstOrNull { server.isSingleplayerOwner(it.gameProfile) } ?: players.singleOrNull() + + /** Prints to the console, not to the game's standard output. */ + fun println(value: Any?) { + ServerConsoleScripts.runner.print(value.toString()) + } + + fun print(value: Any?) = println(value) +} + +/** Compiles server snippets off the server thread and runs them on it. */ +object ServerConsoleScripts { + const val SCRIPT_NAME = "console.$SERVER_CONSOLE_SCRIPT_EXTENSION" + + internal val runner = SnippetRunner(SCRIPT_NAME, LogManager.getLogger("Server Console"), "HollowEngine-ServerConsoleCompiler") + + /** Touched only on the server thread, where snippets are constructed. */ + private var scope: CoroutineScope? = null + private var scopeServer: MinecraftServer? = null + + /** Compiles and runs [code] on [server]'s thread, and hands back what it printed and returned. */ + suspend fun evaluate(server: MinecraftServer, code: String): SnippetRun = + runner.run(code, server.dispatcher) { listOf(scopeOf(server), server) } + + /** Cancels every coroutine server snippets have launched. Call it on the server thread. */ + fun stop() { + scope?.cancel() + scope = null + scopeServer = null + } + + /** A child of the server's own scope, so a stopping server takes the snippets' coroutines with it. */ + private fun scopeOf(server: MinecraftServer): CoroutineScope { + val current = scope + if (current != null && scopeServer === server && current.coroutineContext.job.isActive) return current + return CoroutineScope( + SupervisorJob(server.coroutineScope.coroutineContext.job) + server.dispatcher + LogicalSide.SERVER + + CoroutineName("Server console snippets"), + ).also { + scope = it + scopeServer = server + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/console/SnippetRunner.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/console/SnippetRunner.kt new file mode 100644 index 000000000..a85e8d407 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/console/SnippetRunner.kt @@ -0,0 +1,79 @@ +package ru.hollowhorizon.hollowengine.common.scripting.console + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext +import org.apache.logging.log4j.Logger +import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment +import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptResult +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptCompilationException +import java.util.concurrent.Executors +import kotlin.script.experimental.api.constructorArgs + +/** + * What one snippet left behind: the lines it printed while it ran, and either its result or why it did + * not produce one. A [ScriptCompilationException] means it never compiled. + */ +class SnippetRun(val output: List, val result: Result) + +/** + * Compiles console snippets off the game thread and runs them on it. Every snippet is echoed to [logger] + * along with what it printed and what it returned, so the console shows the same whoever typed it. + */ +class SnippetRunner(private val scriptName: String, private val logger: Logger, compilerThreadName: String) { + private val compiler = Executors.newSingleThreadExecutor { task -> + Thread(task, compilerThreadName).apply { isDaemon = true } + }.asCoroutineDispatcher() + + /** Lines printed by the snippet running right now. Touched only on the thread snippets run on. */ + private var capture: MutableList? = null + + /** Called by the snippet base classes; what a snippet prints after it returned only reaches the log. */ + fun print(line: String) { + logger.info(line) + capture?.add(line) + } + + /** Compiles [code], then runs it on [gameThread] with the script constructor's [arguments]. */ + suspend fun run(code: String, gameThread: CoroutineDispatcher, arguments: () -> List): SnippetRun { + val environment = ScriptingEnvironment.currentOrNull() + if (environment == null) { + logger.warn(COMPILER_MISSING) + return SnippetRun(emptyList(), Result.failure(IllegalStateException(COMPILER_MISSING))) + } + logger.info("> {}", code.trimEnd()) + val compiled = withContext(compiler) { environment.compiler.compile(scriptName, code) } + return withContext(gameThread) { + val output = ArrayList() + capture = output + val result = try { + compiled.mapCatching { script -> + script.evaluate { constructorArgs(*arguments().toTypedArray()) }.getOrThrow() + } + } finally { + capture = null + } + report(result) + SnippetRun(output, result) + } + } + + private fun report(result: Result) { + result.onSuccess { value -> if (value.hasValue) logger.info("= {}", value.value) } + val error = result.exceptionOrNull() ?: return + val reports = (error as? ScriptCompilationException)?.reports + if (reports == null) { + logger.error("The snippet failed", error) + return + } + reports.filter { it.severity.isError() }.forEach { report -> + val start = report.range.start + if (start.line < 0) logger.error(report.message) + else logger.error("{}:{}: {}", start.line + 1, start.column + 1, report.message) + } + } + + companion object { + const val COMPILER_MISSING = "Kotlin snippets need the compiler addon, which is not installed" + } +} 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 fcf0ee5b7..9155c26e5 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 @@ -28,8 +28,29 @@ interface ScriptingAnalyzer { 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 + + /** + * Classes and top-level functions and properties on the script classpath whose name matches + * [query]: a simple name, part of one, or a name qualified by part of its package. Closest first. + */ + fun searchSymbols(query: String, limit: Int): List = emptyList() + + /** + * Source of the class, member or top-level declaration [qualifiedName] names, read from a sources + * jar when there is one and decompiled otherwise; [DefinitionLocation.offset] points at the name. + */ + fun symbolSource(qualifiedName: String): DefinitionLocation? = null } +enum class SymbolKind { CLASS, ANNOTATION, FUNCTION, PROPERTY } + +/** A declaration found by [ScriptingAnalyzer.searchSymbols]; [signature] is empty for classes. */ +data class SymbolMatch( + val kind: SymbolKind, + val qualifiedName: String, + val signature: String = "", +) + /** * Receives completions as they are produced. Returning `false` means the request is stale (the * caret moved on) and the analyzer should stop enumerating instead of finishing a scan nobody is diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssCompletions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssCompletions.kt index c112c27d2..db7d8b38e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssCompletions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssCompletions.kt @@ -18,6 +18,8 @@ internal fun hssCompletions(text: String, offset: Int): List { val model = HssDocumentModel(text) val state = HssLexer(before, model.keyframeNames).scanState() + VariablePrefix.find(before)?.let { reference -> return variableCompletions(reference.groupValues[1], model) } + return when (state.region) { HssRegion.VALUE -> valueCompletions(before, caret, state.property, state.valueStart, model) HssRegion.PROPERTY -> propertyCompletions(identifierPrefix(before)) @@ -28,6 +30,28 @@ internal fun hssCompletions(text: String, offset: Int): List { private val KeyframeSelectors = listOf("from", "to", "0%", "25%", "50%", "75%", "100%") +/** A `$name` being typed in a value. */ +private val VariablePrefix = Regex("""\$([A-Za-z0-9_-]*)$""") + +/** The variables and sets in scope, each with what it stands for. */ +private fun variableCompletions(prefix: String, model: HssDocumentModel): List { + val variables = model.scope.variables.values.map { Triple(it.name, ": " + it.value, it.source ?: "this stylesheet") } + val sets = model.scope.sets.values.map { Triple(it.name, " { … }", "set") } + return (variables + sets) + .filter { (name, _, _) -> completionMatches(prefix, name) } + .map { (name, value, source) -> + declarationCompletionItem { + show = "$" + name + insert = name + this.name = name + tag = CompletionItemTag.PROPERTY + fqName = null + middle = value + tail = source + } + } +} + private fun propertyCompletions(prefix: String): List = HssSchema.properties .flatMap { property -> property.namesFor(prefix).map { property to it } } 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 7aa36cf91..f038d9bc9 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 @@ -2,6 +2,7 @@ package ru.hollowhorizon.hollowengine.common.scripting.ide.ui import ru.hollowhorizon.hollowengine.client.ui.style.HssDeclaration import ru.hollowhorizon.hollowengine.client.ui.style.HssSchema +import ru.hollowhorizon.hollowengine.client.ui.style.HssVariableReference import ru.hollowhorizon.hollowengine.client.ui.style.HssValueKind import ru.hollowhorizon.hollowengine.client.ui.toStylePatch import ru.hollowhorizon.hollowengine.common.scripting.ide.Diagnostic @@ -22,6 +23,12 @@ internal fun hssDiagnostics(text: String): List { for (error in model.errors) { diagnostics += offsets.diagnostic(error.position, error.endPosition, Severity.ERROR, error.messageText) } + for ((import, reason) in model.scope.failures) { + if (import.start >= 0) diagnostics += offsets.diagnostic(import.start, import.end, Severity.ERROR, reason) + } + for (variable in model.variables) { + diagnostics += unknownVariables(variable.value, variable.valueStart, model, offsets) + } for (declaration in model.declarations) { diagnostics += declarationDiagnostics(declaration, model, offsets) } @@ -34,6 +41,7 @@ private fun declarationDiagnostics( offsets: LineOffsets, ): List { val propertyRange = declaration.propertyRange ?: return emptyList() + if (declaration.isApply) return unknownSet(declaration, model, offsets) val property = HssSchema.find(declaration.property) ?: return listOf( offsets.diagnostic( @@ -45,11 +53,13 @@ private fun declarationDiagnostics( ) val valueRange = declaration.valueRange ?: return emptyList() + val unknown = unknownVariables(declaration.value, declaration.valueStart, model, offsets) + if (unknown.isNotEmpty()) return unknown val diagnostics = ArrayList() try { // Most properties parse their value lazily inside the patch writer, so the value is // only really checked once the modifier is applied to a patch. - property.compile(declaration.value.trim())?.let { listOf(it).toStylePatch() } + property.compile(model.scope.substitute(declaration.value).trim())?.let { listOf(it).toStylePatch() } } catch (exception: IllegalArgumentException) { diagnostics += offsets.diagnostic( valueRange.first, @@ -69,6 +79,31 @@ private fun declarationDiagnostics( return diagnostics } +/** `@apply $name;` naming a set that neither this stylesheet nor its imports declare. */ +private fun unknownSet(declaration: HssDeclaration, model: HssDocumentModel, offsets: LineOffsets): List { + val range = declaration.valueRange ?: return emptyList() + val name = declaration.value.trim().removePrefix("$") + if (name in model.scope.sets) return emptyList() + return listOf( + offsets.diagnostic(range.first, range.last + 1, Severity.ERROR, "No set '$$name' in this stylesheet or its imports"), + ) +} + +/** Every `$name` in [value] that no variable in scope answers to. */ +private fun unknownVariables(value: String, valueStart: Int, model: HssDocumentModel, offsets: LineOffsets): List { + if (valueStart < 0) return emptyList() + return HssVariableReference.findAll(value) + .filter { it.groupValues[1] !in model.scope.variables } + .map { match -> + offsets.diagnostic( + valueStart + match.range.first, + valueStart + match.range.last + 1, + Severity.ERROR, + "No variable '${match.value}' in this stylesheet or its imports", + ) + }.toList() +} + /** Flags `animations: fade …` when no `@keyframes fade` exists in the document. */ private fun keyframeDiagnostics( declaration: HssDeclaration, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDocumentModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDocumentModel.kt index 72547c49d..90af7a346 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDocumentModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssDocumentModel.kt @@ -1,9 +1,12 @@ package ru.hollowhorizon.hollowengine.common.scripting.ide.ui import ru.hollowhorizon.hollowengine.client.ui.style.HssDeclaration +import ru.hollowhorizon.hollowengine.client.ui.style.HssImport import ru.hollowhorizon.hollowengine.client.ui.style.HssParseException import ru.hollowhorizon.hollowengine.client.ui.style.HssParseResult +import ru.hollowhorizon.hollowengine.client.ui.style.HssScope import ru.hollowhorizon.hollowengine.client.ui.style.HssSelector +import ru.hollowhorizon.hollowengine.client.ui.style.HssVariable import ru.hollowhorizon.hollowengine.client.ui.style.parseHssRecovering /** @@ -16,11 +19,21 @@ internal class HssDocumentModel(val text: String) { val errors: List get() = result.errors - val keyframeNames: Set = result.document.keyframes.mapTo(LinkedHashSet()) { it.name } + /** The variables in scope, imported ones included, and what the imports could not read. */ + val scope: HssScope = HssScope.of(result.document) + + /** The document's own `$name: value` declarations. */ + val variables: List = result.document.variables + + val imports: List = result.document.imports + + val keyframeNames: Set = (result.document.keyframes + scope.importedKeyframes) + .mapTo(LinkedHashSet()) { it.name } /** Every declaration in the document, rules and keyframes alike, in source order. */ val declarations: List = buildList { result.document.rules.forEach { addAll(it.declarations) } + result.document.sets.forEach { addAll(it.declarations) } result.document.keyframes.forEach { keyframes -> keyframes.frames.forEach { addAll(it.declarations) } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssInlayHints.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssInlayHints.kt index e43c2274c..3c4315ca7 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssInlayHints.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssInlayHints.kt @@ -1,5 +1,6 @@ package ru.hollowhorizon.hollowengine.common.scripting.ide.ui +import ru.hollowhorizon.hollowengine.client.ui.style.HssVariableReference import ru.hollowhorizon.hollowengine.client.ui.style.parseColor import ru.hollowhorizon.hollowengine.common.scripting.ide.InlayAction import ru.hollowhorizon.hollowengine.common.scripting.ide.InlayContent @@ -27,10 +28,32 @@ internal fun hssInlayHints(model: HssDocumentModel): List { } hints += locationHints(declaration.value, valueStart) hints += colorHints(declaration.value, valueStart) + hints += variableColorHints(declaration.value, valueStart, model) + } + for (variable in model.variables) { + if (variable.valueStart < 0) continue + hints += colorHints(variable.value, variable.valueStart) + hints += variableColorHints(variable.value, variable.valueStart, model) } return hints } +/** + * A variable that stands for a color shows that color in front of its name. It cannot be picked + * there: the color belongs to wherever the variable is declared. + */ +private fun variableColorHints(value: String, valueStart: Int, model: HssDocumentModel): List = + HssVariableReference.findAll(value).mapNotNull { match -> + val resolved = model.scope.substitute(match.value).trim() + val literal = hssColorLiterals(resolved).singleOrNull()?.takeIf { it.text.length == resolved.length } + ?: return@mapNotNull null + InlayHint( + index = valueStart + match.range.first, + content = listOf(InlayContent.Swatch(literal.argb)), + tags = listOf(InlayTags.COLOR), + ) + }.toList() + /** * Every color literal in a declaration value gets a clickable chip in front of it, so a stylesheet * shows the colors it actually paints and any of them can be re-picked without hand-writing hex. @@ -53,7 +76,7 @@ private fun colorHints(value: String, valueStart: Int): List = internal data class HssColorLiteral(val start: Int, val end: Int, val text: String, val argb: Int) private val ColorLiteralRegex = Regex( - """#[0-9a-fA-F]{6,8}\b|\brgba?\s*\([^)]*\)|\b(?:transparent|white|black)\b""", + """\balpha\s*\(\s*#[0-9a-fA-F]{6,8}\s*,[^)]*\)|#[0-9a-fA-F]{6,8}\b|\brgba?\s*\([^)]*\)|\b(?:transparent|white|black)\b""", ) internal fun hssColorLiterals(value: String): List = @@ -62,7 +85,7 @@ internal fun hssColorLiterals(value: String): List = HssColorLiteral(match.range.first, match.range.last + 1, match.value, color.toArgb()) }.toList() -/** Renders a picked colour back into a value the stylesheet parses: `#RRGGBB`, or `#RRGGBBAA`. */ +/** Renders a picked color back into a value the stylesheet parses: `#RRGGBB`, or `#RRGGBBAA`. */ internal fun hssColorLiteralText(argb: Int): String { val alpha = argb ushr 24 and 0xFF val rgb = "%06X".format(argb and 0xFFFFFF) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssLexer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssLexer.kt index 5e291d095..ccf305a87 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssLexer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssLexer.kt @@ -33,7 +33,7 @@ internal data class HssScanState( /** * Scanner behind HSS highlighting. It tracks block depth and `@keyframes` bodies, so a * keyframe selector (`from`, `50%`) is never mistaken for a property and the declarations - * nested two blocks deep keep their colours. + * nested two blocks deep keep their colors. * * Value tokens are classified against [UiLanguageCatalog], which means the keywords a * property accepts are highlighted as keywords without any list living here. @@ -48,6 +48,9 @@ internal class HssLexer( private var keyframesDepth = -1 private var pendingKeyframes = false private var inValue = false + + /** Between a top-level `$name` and its `:`, where the next `:` starts the variable's value. */ + private var pendingVariable = false private var property: String? = null private var valueStart = 0 private val spans = ArrayList() @@ -88,6 +91,7 @@ internal class HssLexer( } private fun region(): HssRegion = when { + inValue -> HssRegion.VALUE depth == 0 -> HssRegion.SELECTOR depth == keyframesDepth -> HssRegion.KEYFRAME_SELECTOR inValue -> HssRegion.VALUE @@ -98,6 +102,7 @@ internal class HssLexer( emit(index, index + 1, TokenType.DEFAULT) index++ inValue = false + pendingVariable = false property = null if (open) { depth++ @@ -137,7 +142,21 @@ internal class HssLexer( } private fun readSelectorToken() { + if (pendingVariable && text[index] == ':') { + emit(index, index + 1, TokenType.DEFAULT) + index++ + pendingVariable = false + inValue = true + property = null + valueStart = index + return + } when (val char = text[index]) { + '$' -> { + readVariable() + pendingVariable = true + } + '@' -> readAtRule() '.' -> readPrefixed(TokenType.FUNCTION) '#' -> readPrefixed(TokenType.EXTENSION_RECEIVER) @@ -186,6 +205,14 @@ internal class HssLexer( private fun readPropertyToken() { val char = text[index] when { + text.startsWith("@apply", index) -> { + emit(index, index + "@apply".length, TokenType.KEYWORD) + index += "@apply".length + inValue = true + property = null + valueStart = index + } + char == ':' -> { emit(index, index + 1, TokenType.DEFAULT) index++ @@ -207,6 +234,7 @@ internal class HssLexer( private fun readValueToken() { val char = text[index] when { + char == '$' -> readVariable() char == '"' || char == '\'' -> readString() char == '#' -> readWhile(TokenType.NUMERIC_LITERAL) { it.isLetterOrDigit() || it == '#' } char.isDigit() || (char == '-' && text.getOrNull(index + 1)?.isDigit() == true) -> @@ -217,6 +245,14 @@ internal class HssLexer( } } + /** `$name`, whether declared or used. */ + private fun readVariable() { + val start = index + index++ + while (index < text.length && isIdentifierPart(text[index])) index++ + emit(start, index, TokenType.VARIABLE) + } + private fun readValueWord() { val start = index while (index < text.length && isIdentifierPart(text[index])) index++ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/effects/VfxEffects.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/effects/VfxEffects.kt new file mode 100644 index 000000000..e66cdb757 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/effects/VfxEffects.kt @@ -0,0 +1,28 @@ +package ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects + +import net.minecraft.core.BlockPos +import net.minecraft.world.entity.Entity +import net.minecraft.world.level.Level +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.common.vfx.Vfx +import ru.hollowhorizon.hollowengine.common.vfx.VfxHandle + +/** + * Plays effect from a script. + * + * The handle that comes back is what steers the effect afterward: stopping it, moving it, or writing + * into its data store, so its expressions can read the value as `d.`. + * + * ```kotlin + * val aura = boss.playVfx("mypack:vfx/aura") + * aura[Charge] = 0.5f + * aura.stop() + * ``` + */ +fun Level.playVfx(position: Vec3, effect: String): VfxHandle = Vfx.play(this, position, effect) + +fun Level.playVfx(position: BlockPos, effect: String): VfxHandle = + Vfx.play(this, Vec3(position.x + 0.5, position.y.toDouble(), position.z + 0.5), effect) + +/** Plays [effect] on this entity; it follows it and stops when the entity is gone. */ +fun Entity.playVfx(effect: String): VfxHandle = Vfx.play(this, effect) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotClick.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotClick.kt index 03395382b..7111dd075 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotClick.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotClick.kt @@ -6,7 +6,7 @@ import ru.hollowhorizon.hollowengine.common.utils.areStacksEqual import kotlin.math.max import kotlin.math.min -enum class SlotIntentKind { CLICK, QUICK_MOVE, DROP, DROP_CARRIED, DISTRIBUTE } +enum class SlotIntentKind { CLICK, QUICK_MOVE, DROP, DROP_CARRIED, DISTRIBUTE, COLLECT, CLONE } enum class SlotButton { LEFT, RIGHT } @@ -45,6 +45,12 @@ class SlotIntent internal constructor( fun distribute(slots: List, mode: SlotDistributeMode) = SlotIntent(SlotIntentKind.DISTRIBUTE, slots = slots, distribute = mode) + + /** Double click: gathers items like the carried stack from the other slots onto the cursor. */ + fun collect(slot: Int) = SlotIntent(SlotIntentKind.COLLECT, slot = slot) + + /** Middle click in creative: a full stack of what the slot holds, onto an empty cursor. */ + fun clone(slot: Int) = SlotIntent(SlotIntentKind.CLONE, slot = slot) } } @@ -72,6 +78,8 @@ fun SlotLayout.applyClick(state: SlotState, intent: SlotIntent): SlotClickResult SlotIntentKind.DROP -> dropFromSlot(state, intent.slot, intent.all) SlotIntentKind.DROP_CARRIED -> dropCarried(state, intent.all) SlotIntentKind.DISTRIBUTE -> SlotClickResult(distribute(state, intent.slots, intent.distribute)) + SlotIntentKind.COLLECT -> SlotClickResult(collect(state, intent.slot)) + SlotIntentKind.CLONE -> SlotClickResult(clone(state, intent.slot)) } private fun SlotLayout.copyClick(state: SlotState, slot: Int, count: Int): Boolean { @@ -118,6 +126,7 @@ private fun SlotLayout.leftClick(state: SlotState, slot: Int): Boolean { } if (current.areStacksEqual(carried)) { + if (!rules.canInsert.matches(carried)) return takeOntoCursor(state, slot, rules) val moved = min(carried.count, effectiveLimit(rules, current) - current.count) if (moved <= 0) return false state[slot] = current.copyWithCount(current.count + moved) @@ -157,12 +166,68 @@ private fun SlotLayout.rightClick(state: SlotState, slot: Int): Boolean { } if (!current.areStacksEqual(carried)) return false + if (!rules.canInsert.matches(carried)) return takeOntoCursor(state, slot, rules) if (current.count >= effectiveLimit(rules, current)) return false state[slot] = current.copyWithCount(current.count + 1) state.carried = carried.without(1) return true } +/** + * Clicking a slot that refuses the carried item, while holding more of what it holds, takes from it onto + * the cursor instead: how vanilla's result slots behave. Only as much as the cursor stack has room for. + */ +private fun takeOntoCursor(state: SlotState, slot: Int, rules: SlotRules): Boolean { + val current = state[slot] + val carried = state.carried + if (!rules.canExtract.matches(current)) return false + val taken = min(current.count, carried.maxStackSize - carried.count) + if (taken <= 0) return false + state.carried = carried.copyWithCount(carried.count + taken) + state[slot] = current.without(taken) + return true +} + +/** + * Gathers items like the carried stack onto the cursor, up to a full stack. Partial stacks go first so a + * double click tidies an inventory rather than breaking up full stacks. Zones whose server has a say the + * client cannot see (a result slot) are left alone, as are copy zones, which hold no real items. + */ +private fun SlotLayout.collect(state: SlotState, clicked: Int): Boolean { + val carried = state.carried + if (carried.isEmpty || clicked !in 0 until totalSize) return false + val limit = carried.maxStackSize + var gathered = carried.count + + for (fullStacks in listOf(false, true)) { + for (zone in zones) { + if (zone.copyOnClick || !zone.isPredictable) continue + for (slot in zone.flatIndices) { + if (gathered >= limit) break + val stack = state[slot] + if (stack.isEmpty || !stack.areStacksEqual(carried)) continue + if ((stack.count >= stack.maxStackSize) != fullStacks) continue + if (!rulesAt(slot).canExtract.matches(stack)) continue + val taken = min(stack.count, limit - gathered) + state[slot] = stack.without(taken) + gathered += taken + } + } + } + if (gathered == carried.count) return false + state.carried = carried.copyWithCount(gathered) + return true +} + +/** Creative's middle click. Makes items from nothing, so the server only honors it from a creative player. */ +private fun clone(state: SlotState, slot: Int): Boolean { + if (!state.carried.isEmpty || slot !in 0 until state.size) return false + val current = state[slot] + if (current.isEmpty) return false + state.carried = current.copyWithCount(current.maxStackSize) + return true +} + /** * Sends a slot's contents around the ring, trying each following zone in turn and stopping at the first * that took anything. Zones that accept nothing are skipped rather than swallowing the gesture, so a @@ -171,7 +236,7 @@ private fun SlotLayout.rightClick(state: SlotState, slot: Int): Boolean { private fun SlotLayout.quickMove(state: SlotState, slot: Int): Boolean { if (slot !in 0 until totalSize) return false val zone = zoneOf(slot) ?: return false - if (!zone.role.canSend) return false + if (!zone.role.canSend || zone.copyOnClick) return false val rules = rulesAt(slot) val stack = state[slot] if (stack.isEmpty || !rules.canExtract.matches(stack)) return false diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotContainer.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotContainer.kt index b8225c481..3b5e2a7a5 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotContainer.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotContainer.kt @@ -67,6 +67,7 @@ class SlotContainer internal constructor( // taking items that are no longer there. syncExternalChanges() if (clientRevision != revision) return sendFullSnapshot() + if (intent.kind == SlotIntentKind.CLONE && !player.abilities.instabuild) return sendFullSnapshot() val working = state.copy() val result = layout.applyClick(working, intent) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotLayout.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotLayout.kt index c85bbd31c..8356a6c6e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotLayout.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/slots/SlotLayout.kt @@ -75,6 +75,9 @@ class SlotZoneLayout( ) { val flatIndices: IntRange get() = offset until offset + size + /** Whether the client can compute a gesture on this zone exactly as the server will. */ + val isPredictable: Boolean get() = predictable && rules.isPredictable + fun rulesAt(local: Int): SlotRules = overrides.firstOrNull { it.index == local }?.rules ?: rules @@ -102,9 +105,8 @@ class SlotLayout( @Transient val totalSize: Int = zones.sumOf { it.size } - /** False when any zone is unpredictable; the client then never applies a click optimistically. */ @Transient - val isPredictable: Boolean = zones.all { it.predictable && it.rules.isPredictable } + val isPredictable: Boolean = zones.all { it.isPredictable } fun zone(name: String): SlotZoneLayout? = zones.firstOrNull { it.name == name } @@ -115,6 +117,25 @@ class SlotLayout( return zone.rulesAt(flat - zone.offset) } + /** + * Whether the client may apply [intent] before the server answers: every zone the gesture can read or + * write has to be predictable. One unpredictable zone (a result slot guarded by a server check) then + * only slows down the gestures that touch it, not every click on the screen. + */ + fun predicts(intent: SlotIntent): Boolean { + if (isPredictable) return true + val involved = when (intent.kind) { + SlotIntentKind.CLICK, SlotIntentKind.DROP, SlotIntentKind.CLONE -> listOf(zoneOf(intent.slot)) + SlotIntentKind.COLLECT -> listOf(zoneOf(intent.slot)) + SlotIntentKind.QUICK_MOVE -> zoneOf(intent.slot) + ?.let { listOf(it) + quickMoveTargets(it.name) } + ?: listOf(null) + SlotIntentKind.DISTRIBUTE -> intent.slots.map(::zoneOf) + SlotIntentKind.DROP_CARRIED -> emptyList() + } + return involved.all { it != null && it.isPredictable } + } + /** Whether this slot edits by copying rather than by moving; see [SlotZoneLayout.copyOnClick]. */ fun copiesAt(flat: Int): Boolean = zoneOf(flat)?.copyOnClick ?: false @@ -141,7 +162,7 @@ class SlotLayout( if (candidate.name == from || ordered.any { it.name == candidate.name }) continue ordered += candidate } - return ordered.filter { it.role.canReceive } + return ordered.filter { it.role.canReceive && !it.copyOnClick } } private companion object { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxBoneAttachment.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxBoneAttachment.kt new file mode 100644 index 000000000..57d1282fe --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxBoneAttachment.kt @@ -0,0 +1,34 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorAsset +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorHidden +import ru.hollowhorizon.hollowengine.common.attachments.editor.EditorName +import ru.hollowhorizon.hollowengine.common.models.RigAttachmentSpec +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f + +/** + * An effect attached to bone, authored in the rig editor. + */ +@Serializable +@SerialName("hollowengine:rig/vfx") +data class VfxBoneAttachmentSpec( + @EditorHidden + override val id: String = "vfx", + @EditorName("hollowengine.gui.vfx.effect") + @EditorAsset(".vfx") + val effect: String = "", + @EditorName("hollowengine.gui.vfx.spawn_offset") + val offset: Vec3f = Vec3f.ZERO, + /** Euler angles in degrees, on top of the bone rotation. */ + @EditorName("hollowengine.gui.vfx.rotation") + val rotation: Vec3f = Vec3f.ZERO, + @EditorName("hollowengine.gui.vfx.scale") + val scale: Float = 1f, + /** Whether it starts as soon as the model is drawn; otherwise a script starts it. */ + @EditorName("hollowengine.gui.vfx.auto_play") + val autoPlay: Boolean = true, +) : RigAttachmentSpec() { + override fun withId(id: String) = copy(id = id) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxEffect.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxEffect.kt new file mode 100644 index 000000000..d8205f768 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxEffect.kt @@ -0,0 +1,163 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.Serializable + +/** One authored effect: a tree of nodes and timeline that drives them. */ +@Serializable +data class VfxEffect( + val version: Int = CURRENT_VERSION, + val nodes: List = emptyList(), + val timeline: VfxTimelineSpec = VfxTimelineSpec(), +) { + /** Every node of tree, parents before children. */ + fun walk(): List = nodes.flatMap { it.walkSelf() } + + fun node(id: String): VfxNodeSpec? = walk().firstOrNull { it.id == id } + + /** The node [id] sits under, or null for a root node or an unknown id. */ + fun parentOf(id: String): VfxNodeSpec? = walk().firstOrNull { parent -> parent.children.any { it.id == id } } + + /** Every expression in the file, so they compile as one unit. */ + fun expressions(): List = walk().flatMap { it.expressions() }.filter { it.isNotBlank() }.distinct() + + /** The effect with [node] put in place of the node with the same id, wherever it sits. */ + fun withNode(node: VfxNodeSpec): VfxEffect = copy(nodes = nodes.map { it.replacing(node) }) + + fun withoutNode(id: String): VfxEffect = copy(nodes = nodes.mapNotNull { it.removing(id) }) + + /** [child] added under [parentId], or at the root when it is null. */ + fun withChild(parentId: String?, child: VfxNodeSpec): VfxEffect { + if (parentId == null) return copy(nodes = nodes + child) + return copy(nodes = nodes.map { it.adding(parentId, child) }) + } + + /** Whether [id] is [ancestor] itself or anywhere under it. */ + fun isWithin(id: String, ancestor: String): Boolean = + node(ancestor)?.walkSelf()?.any { it.id == id } ?: false + + /** + * The node [id] taken from where it is and put under [parentId] (the root when null) at [index], or + * at the end when [index] is out of range. A node cannot go under itself; that leaves the effect as + * it was. + */ + fun withMoved(id: String, parentId: String?, index: Int = Int.MAX_VALUE): VfxEffect { + val moved = node(id) ?: return this + if (parentId != null && isWithin(parentId, id)) return this + val without = withoutNode(id) + if (parentId == null) return without.copy(nodes = without.nodes.inserting(moved, index)) + return without.copy(nodes = without.nodes.map { it.inserting(parentId, moved, index) }) + } + + /** The node [id] moved [offset] places among the nodes that share its parent. */ + fun withShifted(id: String, offset: Int): VfxEffect { + val parent = parentOf(id) + val siblings = parent?.children ?: nodes + val from = siblings.indexOfFirst { it.id == id } + if (from < 0) return this + val to = (from + offset).coerceIn(0, siblings.lastIndex) + if (to == from) return this + return withMoved(id, parent?.id, to) + } + + /** + * A copy of the node [id] and everything under it, with fresh ids, placed right after it, and a copy + * of every timeline track that drove the copied nodes. Returns the effect and the id of the copy. + */ + fun withDuplicate(id: String): Pair? { + val original = node(id) ?: return null + val renamed = HashMap() + val clone = original.copiedWithFreshIds(renamed) + + val parent = parentOf(id) + val index = (parent?.children ?: nodes).indexOfFirst { it.id == id } + 1 + val placed = if (parent == null) copy(nodes = nodes.inserting(clone, index)) + else copy(nodes = nodes.map { it.inserting(parent.id, clone, index) }) + + val tracks = timeline.tracks.mapNotNull { track -> renamed[track.node]?.let { track.copy(node = it) } } + return placed.copy(timeline = placed.timeline.copy(tracks = placed.timeline.tracks + tracks)) to clone.id + } + + /** The effect with every node placed at its origin, switched on and unnamed: what placement leaves. */ + fun withoutPlacement(): VfxEffect = copy(nodes = nodes.map { it.withoutPlacement() }) + + companion object { + const val CURRENT_VERSION = 1 + + val EMPTY = VfxEffect() + } +} + +private fun VfxNodeSpec.withoutPlacement(): VfxNodeSpec = withCommon( + name = "", + enabled = true, + transform = VfxTransform.IDENTITY, + children = children.map { it.withoutPlacement() }, +) + +private fun VfxNodeSpec.walkSelf(): List = buildList { + add(this@walkSelf) + children.forEach { addAll(it.walkSelf()) } +} + +private fun VfxNodeSpec.replacing(node: VfxNodeSpec): VfxNodeSpec { + if (id == node.id) return node + if (children.isEmpty()) return this + return withCommon(children = children.map { it.replacing(node) }) +} + +private fun VfxNodeSpec.removing(target: String): VfxNodeSpec? { + if (id == target) return null + if (children.isEmpty()) return this + return withCommon(children = children.mapNotNull { it.removing(target) }) +} + +private fun List.inserting(node: VfxNodeSpec, index: Int): List = + toMutableList().also { it.add(index.coerceIn(0, size), node) } + +private fun VfxNodeSpec.inserting(parentId: String, child: VfxNodeSpec, index: Int): VfxNodeSpec { + if (id == parentId) return withCommon(children = children.inserting(child, index)) + if (children.isEmpty()) return this + return withCommon(children = children.map { it.inserting(parentId, child, index) }) +} + +/** This node and its children under new ids, recording in [renamed] which old id became which. */ +private fun VfxNodeSpec.copiedWithFreshIds(renamed: MutableMap): VfxNodeSpec { + val fresh = newVfxNodeId(id.substringBefore('-').ifBlank { "node" }) + renamed[id] = fresh + return withCommon(id = fresh, children = children.map { it.copiedWithFreshIds(renamed) }) +} + +private fun VfxNodeSpec.adding(parentId: String, child: VfxNodeSpec): VfxNodeSpec { + if (id == parentId) return withCommon(children = children + child) + if (children.isEmpty()) return this + return withCommon(children = children.map { it.adding(parentId, child) }) +} + +@Serializable +data class VfxTrackCurve( + val channel: Int = 0, + val visible: Boolean = true, + val keys: List = emptyList(), +) + +/** + * One animated property of one node. One curve per scalar component of it. + */ +@Serializable +data class VfxTrack( + val node: String = "", + val property: VfxProperty = VfxProperty.ENABLED, + val curves: List = emptyList(), +) + +@Serializable +data class VfxTimelineSpec( + val duration: Float = 5f, + val loop: Boolean = true, + val tracks: List = emptyList(), +) { + fun track(node: String, property: VfxProperty): VfxTrack? = + tracks.firstOrNull { it.node == node && it.property == property } + + val isEmpty: Boolean get() = tracks.isEmpty() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxEmitterSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxEmitterSpec.kt new file mode 100644 index 000000000..c2e44d30d --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxEmitterSpec.kt @@ -0,0 +1,205 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatables.scalar +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatables.vector +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxModuleSpec + +/** Where an emitter keeps the positions and velocities of its particles. */ +@Serializable +enum class VfxSimulationSpace { + /** The emitter space, that particles follows. */ + LOCAL, + + /** The world space. Particles stay where they were born. */ + WORLD, +} + +/** How many particles appear and when the emitter stops. */ +@Serializable +data class VfxEmission( + /** Particles per second. */ + val rate: VfxValue = VfxValue.Const(20f), + /** Particles released at once when a loop starts. */ + val burst: Int = 0, + /** Seconds the emitter stays active; zero means forever. */ + val duration: Float = 2f, + val loop: Boolean = true, + /** Seconds of silence between loops. */ + val loopDelay: Float = 0f, + val maxParticles: Int = 256, +) + +/** Shapes a particle can be born on. */ +@Serializable +enum class VfxShapeKind { + POINT, SPHERE, BOX, CONE, DISC, LINE, + + /** The surface of a model in its bind pose. */ + MODEL, +} + +/** Which way a newborn particle is sent. */ +@Serializable +enum class VfxDirectionMode { + /** Outwards from the shape. Away from a sphere center, along a cone, up from a disc, off a model surface. */ + SHAPE, + + /** A random direction, whatever the shape. */ + RANDOM, + + /** Always [VfxShape.direction]. */ + FIXED, +} + +/** + * Where particles are born. + */ +@Serializable +data class VfxShape( + val kind: VfxShapeKind = VfxShapeKind.POINT, + val radius: VfxValue = VfxValue.Const(0.5f), + /** Box half-extents, and the end point of a line. */ + val extents: VfxVec3Value = VfxVec3Value.all(0.5f), + /** Cone half-angle in degrees. */ + val angle: VfxValue = VfxValue.Const(25f), + /** 0 puts particles on the surface only, 1 fills the whole volume. */ + val thickness: VfxValue = VfxValue.ONE, + val direction: Vec3f = Vec3f.Y_AXIS, + val directionMode: VfxDirectionMode = VfxDirectionMode.SHAPE, + /** The model whose surface a [VfxShapeKind.MODEL] shape covers. */ + val model: String = "", +) { + fun expressions(): List = radius.sources() + extents.sources() + angle.sources() + thickness.sources() +} + +/** + * What a particle is given at spawn and never changes afterward. + */ +@Serializable +data class VfxSpawn( + /** Seconds the particle lives. */ + val lifetime: VfxValue = VfxValue.Const(1f), + /** Blocks per second along the shape direction. */ + val speed: VfxValue = VfxValue.Const(1f), + /** An extra offset from the point the shape picked. */ + val offset: VfxVec3Value = VfxVec3Value.ZERO, + /** How much of the emitter own velocity the particle keeps, 0 to 1. */ + val inheritVelocity: VfxValue = VfxValue.ZERO, +) { + fun expressions(): List = + lifetime.sources() + speed.sources() + offset.sources() + inheritVelocity.sources() +} + +/** + * The forces every emitter has, as opposed to the ones that come from modules. + */ +@Serializable +data class VfxMotion( + /** Blocks per second squared, pulling down. */ + val gravity: VfxValue = VfxValue.ZERO, + /** Fraction of speed lost per second, 0 to 1. */ + val drag: VfxValue = VfxValue.ZERO, +) { + fun expressions(): List = gravity.sources() + drag.sources() +} + +/** What happens to a particle that a sub-emitter reacts to. */ +@Serializable +enum class VfxParticleEvent { + SPAWN, + + /** Of age, or of a collision that kills it. */ + DEATH, + + /** Touching a block. */ + COLLISION, + + /** All the while it lives; the count is then per second. */ + ALIVE, +} + +@Serializable +data class VfxSubEmission( + val event: VfxParticleEvent = VfxParticleEvent.DEATH, + val count: VfxValue = VfxValue.Const(12f), + val inheritVelocity: VfxValue = VfxValue.ZERO, +) { + fun expressions(): List = count.sources() + inheritVelocity.sources() +} + +/** + * Makes particles and moves them. It draws nothing itself: the renderers directly under it do, one + * each per particle. + */ +@Serializable +@SerialName("hollowengine:vfx/emitter") +data class VfxEmitterSpec( + override val id: String = newVfxNodeId("emitter"), + override val name: String = "Emitter", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + val emission: VfxEmission = VfxEmission(), + val space: VfxSimulationSpace = VfxSimulationSpace.WORLD, + val inheritRotation: Boolean = true, + val inheritScale: Boolean = false, + val shape: VfxShape = VfxShape(), + val spawn: VfxSpawn = VfxSpawn(), + val motion: VfxMotion = VfxMotion(), + val modules: List = emptyList(), + val subEmission: VfxSubEmission? = null, + /** The longest step the simulation may take for this emitter, in seconds. */ + val maxStep: Float = DEFAULT_MAX_STEP, +) : VfxNodeSpec() { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + fun module(id: String): VfxModuleSpec? = modules.firstOrNull { it.id == id } + + override fun expressions(): List = buildList { + addAll(emission.rate.sources()) + addAll(shape.expressions()) + addAll(spawn.expressions()) + addAll(motion.expressions()) + modules.forEach { addAll(it.expressions()) } + subEmission?.let { addAll(it.expressions()) } + } + + override fun animatables(): List = buildList { + add(scalar(VfxProperty.RATE, "rate", emission.rate)) + add(scalar(VfxProperty.SHAPE_RADIUS, "radius", shape.radius)) + add(vector(VfxProperty.SHAPE_EXTENTS, "extents", shape.extents)) + add(scalar(VfxProperty.SHAPE_ANGLE, "angle", shape.angle)) + add(scalar(VfxProperty.SHAPE_THICKNESS, "thickness", shape.thickness)) + add(scalar(VfxProperty.LIFETIME, "lifetime", spawn.lifetime)) + add(scalar(VfxProperty.SPEED, "speed", spawn.speed)) + add(vector(VfxProperty.OFFSET, "spawn_offset", spawn.offset)) + add(scalar(VfxProperty.INHERIT_VELOCITY, "inherit_velocity", spawn.inheritVelocity)) + add(scalar(VfxProperty.GRAVITY, "gravity", motion.gravity)) + add(scalar(VfxProperty.DRAG, "drag", motion.drag)) + + modules.forEach { module -> + val owner = VfxModuleTypes.of(module)?.titleKey + module.animatables().forEach { field -> + add( + VfxAnimatable( + property = VfxProperty.module(module.id, field.field), + titleKey = field.titleKey, + kind = field.kind, + ownerTitleKey = owner, + ) { field.read(module) } + ) + } + } + } +} + +internal const val DEFAULT_MAX_STEP = 1f / 60f diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxFormat.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxFormat.kt new file mode 100644 index 000000000..c2b4786a2 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxFormat.kt @@ -0,0 +1,56 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +/** + * Reads and writes json-like `.vfx` files. + */ +object VfxFormat { + const val EXTENSION = ".vfx" + + /** Where a resource pack keeps its effects. */ + const val RESOURCE_FOLDER = "vfx" + + @Volatile + private var cached: Json? = null + + @Volatile + private var cachedRevision = -1 + + val json: Json + get() { + val revision = VfxModuleRevision.current + cached?.takeIf { cachedRevision == revision }?.let { return it } + + return build().also { + cached = it + cachedRevision = revision + } + } + + fun read(text: String): VfxEffect { + if (text.isBlank()) return VfxEffect.EMPTY + return json.decodeFromString(VfxEffect.serializer(), text) + } + + fun write(effect: VfxEffect): String = json.encodeToString(VfxEffect.serializer(), effect) + + private fun build(): Json { + val module = SerializersModule { + VfxNodeTypes.registerInto(this) + VfxModuleTypes.registerInto(this) + } + return Json { + serializersModule = module + prettyPrint = true + prettyPrintIndent = " " + ignoreUnknownKeys = true + encodeDefaults = false + allowComments = true + allowTrailingComma = true + allowSpecialFloatingPointValues = true + classDiscriminator = "type" + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxMaterialSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxMaterialSpec.kt new file mode 100644 index 000000000..132838bc1 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxMaterialSpec.kt @@ -0,0 +1,137 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** How the color a surface wrote is mixed into what is already on screen. */ +@Serializable +enum class VfxBlend { + /** No blending; the alpha channel only cuts holes. */ + OPAQUE, + + /** Ordinary alpha blending. The only mode that has to be sorted back to front. */ + BLEND, + + /** Adds light: fire, sparks, magic. */ + ADDITIVE, + + /** Multiplies what is behind: shadows, stains, darkening smoke. */ + MULTIPLY; +} + +/** Where a surface takes its light from. */ +@Serializable +enum class VfxLighting { + /** Full bright, whatever the block light is: fire, magic, anything that emits. */ + UNLIT, + + /** The block and skylight of the point it is drawn at. */ + WORLD, +} + +/** + * The part of the texture a surface shows, in 0..1 texture coordinates. + * + * The whole texture by default. A sprite that shares its file with others picks its own corner here; + * a flipbook is cut out of this region by the frame-atlas module, so the two work together. + */ +@Serializable +data class VfxUvRect( + val u0: Float = 0f, + val v0: Float = 0f, + val u1: Float = 1f, + val v1: Float = 1f, +) { + val width: Float get() = u1 - u0 + val height: Float get() = v1 - v0 + + companion object { + val FULL = VfxUvRect() + } +} + +/** + * How a surface is drawn: a plane, a mesh, a trail or a beam. + */ +@Serializable +data class VfxMaterialSpec( + val texture: String = DEFAULT_TEXTURE, + val blend: VfxBlend = VfxBlend.BLEND, + val lighting: VfxLighting = VfxLighting.UNLIT, + val cull: Boolean = false, + val depthTest: Boolean = true, + val depthWrite: Boolean = false, + val uv: VfxUvRect = VfxUvRect.FULL, + val softness: Float = 0f, + val glow: Float = 0f, + /** `namespace:path` of `assets/namespace/shaders/core/path.json`; null for the engine's own. */ + val shader: String? = null, + val uniforms: List = emptyList(), + val samplers: List = emptyList(), +) { + fun expressions(): List = uniforms.flatMap { it.value.sources() } + + companion object { + const val DEFAULT_TEXTURE = "hollowengine:textures/particle/circle.png" + } +} + +/** + * A uniform a shader declares, and what the effect writes into it every frame. + */ +@Serializable +data class VfxUniformSpec( + val name: String = "", + val value: VfxUniformValue = VfxUniformValue.Scalar(), +) + +/** What a uniform holds. A vector also fills a `vec2`, a color a `vec4`. */ +@Serializable +sealed interface VfxUniformValue { + val kind: VfxAnimatableKind + + fun sources(): List + + fun constants(): FloatArray + + @Serializable + @SerialName("float") + data class Scalar(val value: VfxValue = VfxValue.ZERO) : VfxUniformValue { + override val kind get() = VfxAnimatableKind.FLOAT + override fun sources() = value.sources() + override fun constants() = floatArrayOf(value.constantOr(0f)) + } + + @Serializable + @SerialName("vector") + data class Vector(val value: VfxVec3Value = VfxVec3Value.ZERO) : VfxUniformValue { + override val kind get() = VfxAnimatableKind.VEC3 + override fun sources() = value.sources() + override fun constants() = value.constants() + } + + @Serializable + @SerialName("color") + data class Color(val value: VfxColorValue = VfxColorValue.WHITE) : VfxUniformValue { + override val kind get() = VfxAnimatableKind.COLOR + override fun sources() = value.sources() + override fun constants() = value.constants() + } +} + +/** A texture bound to a sampler the shader declares. */ +@Serializable +data class VfxSamplerSpec( + val name: String = "", + val texture: String = "", +) + +/** The timeline tracks the uniforms offer, one per uniform, under `uniforms.`. */ +fun List.animatables(): List = filter { it.name.isNotBlank() }.map { uniform -> + VfxAnimatable( + property = VfxProperty.uniform(uniform.name), + titleKey = uniform.name, + kind = uniform.value.kind, + ownerTitleKey = VfxAnimatables.key("section_uniforms"), + ) { uniform.value.constants() } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxNodes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxNodes.kt new file mode 100644 index 000000000..92d907f62 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxNodes.kt @@ -0,0 +1,100 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f + +/** A fresh node id; readable on purpose, because it appears in timeline tracks and in save files. */ +fun newVfxNodeId(prefix: String): String = "$prefix-${Integer.toHexString((0..0xFFFFFF).random())}" + +/** A node placement inside the effect. */ +@Serializable +data class VfxTransform( + val position: Vec3f = Vec3f.ZERO, + val rotation: Vec3f = Vec3f.ZERO, + val scale: Vec3f = Vec3f.ONES, +) { + companion object { + val IDENTITY = VfxTransform() + } +} + +/** + * One node of an effect. + * + * Node kinds live in [VfxNodeTypes], so an addon can add its own. + */ +@Serializable +abstract class VfxNodeSpec { + abstract val id: String + abstract val name: String + abstract val enabled: Boolean + abstract val transform: VfxTransform + abstract val children: List + + /** A copy with the shared fields replaced, whichever kind of node this is. */ + abstract fun withCommon( + id: String = this.id, + name: String = this.name, + enabled: Boolean = this.enabled, + transform: VfxTransform = this.transform, + children: List = this.children, + ): VfxNodeSpec + + /** Every expression this node evaluates, so a file compiles them all in one pass. */ + open fun expressions(): List = emptyList() + + /** What of this node, besides its placement, the timeline can drive. */ + open fun animatables(): List = emptyList() +} + +/** + * A node that draws on every particle of the emitter it sits directly under, and once in its own + * place anywhere else. + */ +interface VfxParticleRendererSpec { + /** What the node gives each particle it draws; a node drawing itself does not read it. */ + val particle: VfxAppearance + + fun withParticle(particle: VfxAppearance): VfxNodeSpec +} + +/** + * The size, turn and color a renderer gives each particle of its emitter, read again every step for + * as long as the particle lives. Every renderer on one emitter has its own, so a spark and its glow + * can differ. + */ +@Serializable +data class VfxAppearance( + val size: VfxVec3Value = VfxVec3Value.all(0.25f), + val uniformSize: Boolean = true, + val rotation: VfxVec3Value = VfxVec3Value.ZERO, + val color: VfxColorValue = VfxColorValue.WHITE, +) { + fun expressions(): List = size.sources() + rotation.sources() + color.sources() + + fun animatables(): List = listOf( + VfxAnimatables.vector(VfxProperty.SIZE, "size", size), + VfxAnimatables.rotation(VfxProperty.SPIN, "spin", rotation), + VfxAnimatables.color(VfxProperty.COLOR, "color", color), + ) +} + +/** Nothing but a place in the tree: moves, turns, scales and switches off everything under it. */ +@Serializable +@SerialName("hollowengine:vfx/group") +data class VfxGroupSpec( + override val id: String = newVfxNodeId("group"), + override val name: String = "Group", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), +) : VfxNodeSpec() { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxPlayback.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxPlayback.kt new file mode 100644 index 000000000..b83951dc6 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxPlayback.kt @@ -0,0 +1,143 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.Serializable +import net.minecraft.nbt.CompoundTag +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.player.Player +import net.minecraft.world.level.Level +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.client.vfx.VfxScenes +import ru.hollowhorizon.hollowengine.common.data.DataKey +import ru.hollowhorizon.hollowengine.common.data.NbtDataStore +import ru.hollowhorizon.hollowengine.common.network.HollowPacket +import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler +import ru.hollowhorizon.hollowengine.common.network.sendAllInDimension +import ru.hollowhorizon.hollowengine.common.network.sendTrackingEntityAndSelf +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForCompoundNBT +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForEntity +import ru.hollowhorizon.hollowengine.common.utils.nbt.ForVec3 +import java.util.concurrent.atomic.AtomicInteger + +/** + * A running effect, as the side that started it sees it. + * + * The store is an ordinary [NbtDataStore] with [DataKey]s. + */ +class VfxHandle internal constructor( + val id: Int, + val effect: String, + private val level: Level, + private val entity: Entity?, + private var position: Vec3?, +) { + val data = NbtDataStore() + + var isStopped: Boolean = false + private set + + init { + data.onChange = { pushData() } + } + + /** Moves a world-anchored effect. Does nothing for one that follows an entity. */ + fun moveTo(next: Vec3) { + if (entity != null || isStopped) return + position = next + broadcast(VfxMovePacket(id, next)) + } + + /** + * Stops the effect: emitters shut down and what is already alive lives out its lifetime, unless + * [immediate], which clears everything this frame. + */ + fun stop(immediate: Boolean = false) { + if (isStopped) return + isStopped = true + broadcast(VfxStopPacket(id, immediate)) + } + + /** Writes a value the effect expressions can read as `d.`. */ + operator fun set(key: DataKey, value: T) { + data[key] = value + } + + operator fun get(key: DataKey): T? = data[key] + + /** Sends the whole store again, for a client that has just started tracking this effect. */ + fun update() { + if (isStopped) return + broadcast(playPacket()) + } + + internal fun playPacket() = VfxPlayPacket(id, effect, position, entity, data.save()) + + private fun pushData() { + if (isStopped) return + broadcast(VfxDataPacket(id, data.save())) + } + + private fun broadcast(packet: HollowPacket) { + if (entity != null) packet.sendTrackingEntityAndSelf(entity) else packet.sendAllInDimension(level) + } +} + +/** + * Starts effects and hands out their handles. + */ +object Vfx { + private val nextId = AtomicInteger(1) + + /** Plays [effect] at a point in the world. */ + fun play(level: Level, position: Vec3, effect: String): VfxHandle = + start(VfxHandle(nextId.getAndIncrement(), effect, level, null, position)) + + /** Plays [effect] on an entity, following it while it lives. */ + fun play(entity: Entity, effect: String): VfxHandle = + start(VfxHandle(nextId.getAndIncrement(), effect, entity.level(), entity, null)) + + private fun start(handle: VfxHandle): VfxHandle { + handle.update() + return handle + } +} + +@Serializable +@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) +class VfxPlayPacket( + val handle: Int = 0, + val effect: String = "", + val position: @Serializable(ForVec3::class) Vec3? = null, + val entity: @Serializable(ForEntity::class) Entity? = null, + val data: @Serializable(ForCompoundNBT::class) CompoundTag = CompoundTag(), +) : HollowPacket { + override fun handle(player: Player) { + VfxScenes.play(this) + } +} + +@Serializable +@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) +class VfxStopPacket(val handle: Int = 0, val immediate: Boolean = false) : HollowPacket { + override fun handle(player: Player) { + VfxScenes.stop(handle, immediate) + } +} + +@Serializable +@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) +class VfxMovePacket(val handle: Int = 0, val position: @Serializable(ForVec3::class) Vec3 = Vec3.ZERO) : HollowPacket { + override fun handle(player: Player) { + VfxScenes.move(handle, position) + } +} + +@Serializable +@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT) +class VfxDataPacket( + val handle: Int = 0, + val data: @Serializable(ForCompoundNBT::class) CompoundTag = CompoundTag(), +) : HollowPacket { + override fun handle(player: Player) { + VfxScenes.applyData(handle, data) + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxProperty.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxProperty.kt new file mode 100644 index 000000000..ae34160a1 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxProperty.kt @@ -0,0 +1,117 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.Serializable + +/** + * The id of one property of a node that a timeline track can drive, such as `transform.position`, + * `modules..` or `uniforms.`. + */ +@Serializable +@JvmInline +value class VfxProperty(val id: String) { + override fun toString(): String = id + + companion object { + val ENABLED = VfxProperty("enabled") + val POSITION = VfxProperty("transform.position") + val ROTATION = VfxProperty("transform.rotation") + val SCALE = VfxProperty("transform.scale") + + val RATE = VfxProperty("emission.rate") + val SHAPE_RADIUS = VfxProperty("shape.radius") + val SHAPE_EXTENTS = VfxProperty("shape.extents") + val SHAPE_ANGLE = VfxProperty("shape.angle") + val SHAPE_THICKNESS = VfxProperty("shape.thickness") + val LIFETIME = VfxProperty("spawn.lifetime") + val SPEED = VfxProperty("spawn.speed") + val OFFSET = VfxProperty("spawn.offset") + val INHERIT_VELOCITY = VfxProperty("spawn.inherit_velocity") + val SIZE = VfxProperty("appearance.size") + val SPIN = VfxProperty("appearance.rotation") + val COLOR = VfxProperty("appearance.color") + val GRAVITY = VfxProperty("motion.gravity") + val DRAG = VfxProperty("motion.drag") + + val TINT = VfxProperty("tint") + val WIDTH = VfxProperty("width") + val BEAM_END = VfxProperty("beam.end") + val BEAM_NOISE = VfxProperty("beam.noise") + val SHAKE_STRENGTH = VfxProperty("shake.strength") + val SHAKE_FREQUENCY = VfxProperty("shake.frequency") + + fun module(moduleId: String, field: String) = VfxProperty("modules.$moduleId.$field") + + fun uniform(name: String) = VfxProperty("uniforms.$name") + } +} + +/** What a timeline track can drive. */ +enum class VfxAnimatableKind(val channels: Int) { + /** On or off, held between keys instead of interpolated. */ + TOGGLE(1), FLOAT(1), VEC3(3), ROTATION(3), COLOR(4), +} + +/** + * One property a timeline may animate, and its authored value, which a track starts from. + */ +class VfxAnimatable( + val property: VfxProperty, + val titleKey: String, + val kind: VfxAnimatableKind, + val ownerTitleKey: String? = null, + val read: () -> FloatArray, +) + +/** + * The properties a timeline can animate. + * + * Placement is common to every node; the rest each node kind declares in [VfxNodeSpec.animatables]. + */ +object VfxAnimatables { + fun forNode(node: VfxNodeSpec): List = common(node) + node.animatables() + + fun of(node: VfxNodeSpec, property: VfxProperty): VfxAnimatable? = + forNode(node).firstOrNull { it.property == property } + + fun scalar(property: VfxProperty, title: String, value: VfxValue, owner: String? = null) = + VfxAnimatable(property, key(title), VfxAnimatableKind.FLOAT, owner) { floatArrayOf(value.constantOr(0f)) } + + fun vector(property: VfxProperty, title: String, value: VfxVec3Value, owner: String? = null) = + VfxAnimatable(property, key(title), VfxAnimatableKind.VEC3, owner) { value.constants() } + + fun rotation(property: VfxProperty, title: String, value: VfxVec3Value) = + VfxAnimatable(property, key(title), VfxAnimatableKind.ROTATION) { value.constants() } + + fun color(property: VfxProperty, title: String, value: VfxColorValue, owner: String? = null) = + VfxAnimatable(property, key(title), VfxAnimatableKind.COLOR, owner) { value.constants() } + + fun key(name: String) = "hollowengine.gui.vfx.$name" + + private fun common(node: VfxNodeSpec): List = listOf( + VfxAnimatable(VfxProperty.ENABLED, key("property_enabled"), VfxAnimatableKind.TOGGLE) { + floatArrayOf(if (node.enabled) 1f else 0f) + }, + VfxAnimatable(VfxProperty.POSITION, key("property_position"), VfxAnimatableKind.VEC3) { + node.transform.position.let { floatArrayOf(it.x, it.y, it.z) } + }, + VfxAnimatable(VfxProperty.ROTATION, key("property_rotation"), VfxAnimatableKind.ROTATION) { + node.transform.rotation.let { floatArrayOf(it.x, it.y, it.z) } + }, + VfxAnimatable(VfxProperty.SCALE, key("property_scale"), VfxAnimatableKind.VEC3) { + node.transform.scale.let { floatArrayOf(it.x, it.y, it.z) } + }, + ) +} + +/** The expression sources inside a value, for the one-pass compile of a file. */ +fun VfxValue.sources(): List = when (this) { + is VfxValue.Expr -> listOf(source).filter { it.isNotBlank() } + else -> emptyList() +} + +fun VfxVec3Value.sources(): List = x.sources() + y.sources() + z.sources() + +fun VfxColorValue.sources(): List = when (this) { + is VfxColorValue.Channels -> r.sources() + g.sources() + b.sources() + a.sources() + else -> emptyList() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxRegistries.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxRegistries.kt new file mode 100644 index 000000000..0a5e6c382 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxRegistries.kt @@ -0,0 +1,193 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import kotlinx.serialization.modules.SerializersModuleBuilder +import kotlinx.serialization.modules.polymorphic +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionHandle +import ru.hollowhorizon.hollowengine.api.extensions.ExtensionPoints +import ru.hollowhorizon.hollowengine.common.utils.rl +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxCollisionSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxModuleSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxNoiseSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvAnimationSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxVelocityOverLifetimeSpec +import kotlin.reflect.KClass + +/** + * One kind of node an effect may contain: how it is stored, and what the editor calls it. + */ +class VfxNodeType( + val id: String, + val specClass: KClass, + val serializer: KSerializer, + val titleKey: String, + val icon: String? = null, + val createDefault: (() -> S)? = null, +) { + val key: ResourceLocation = id.rl +} + +object VfxNodeTypes { + val point = ExtensionPoints.create>("hollowengine:vfx/node_types".rl) + + init { + point.onChange(VfxModuleRevision::invalidate) + + builtIn("group", VfxGroupSpec.serializer(), "vfx/node_group.svg") { VfxGroupSpec() } + builtIn("emitter", VfxEmitterSpec.serializer(), "vfx/node_emitter.svg") { + VfxEmitterSpec(children = listOf(VfxPlaneSpec())) + } + builtIn("plane", VfxPlaneSpec.serializer(), "vfx/node_plane.svg") { VfxPlaneSpec() } + builtIn("cube", VfxCubeSpec.serializer(), "vfx/node_cube.svg") { VfxCubeSpec() } + builtIn("sphere", VfxSphereSpec.serializer(), "vfx/node_sphere.svg") { VfxSphereSpec() } + builtIn("cylinder", VfxCylinderSpec.serializer(), "vfx/node_cylinder.svg") { VfxCylinderSpec() } + builtIn("model", VfxModelSpec.serializer(), "vfx/node_model.svg") { VfxModelSpec() } + builtIn("trail", VfxTrailSpec.serializer(), "vfx/node_trail.svg") { VfxTrailSpec() } + builtIn("beam", VfxBeamSpec.serializer(), "vfx/node_beam.svg") { VfxBeamSpec() } + builtIn("post_effect", VfxPostEffectSpec.serializer(), "vfx/node_post_effect.svg") { VfxPostEffectSpec() } + builtIn("sky", VfxSkySpec.serializer(), "vfx/node_sky.svg") { VfxSkySpec() } + builtIn("camera_shake", VfxCameraShakeSpec.serializer(), "vfx/node_camera_shake.svg") { VfxCameraShakeSpec() } + } + + private inline fun builtIn( + name: String, + serializer: KSerializer, + icon: String, + noinline createDefault: () -> S, + ) = register( + VfxNodeType( + id = "hollowengine:vfx/$name", + specClass = S::class, + serializer = serializer, + titleKey = "hollowengine.gui.vfx.node_$name", + icon = "hollowengine:textures/gui/icons/$icon", + createDefault = createDefault, + ) + ) + + fun register(type: VfxNodeType<*>): ExtensionHandle = point.register(type.key, type) + + val all: List> get() = point.extensions + + fun of(spec: VfxNodeSpec): VfxNodeType<*>? = all.firstOrNull { it.specClass.isInstance(spec) } + + fun registerInto(builder: SerializersModuleBuilder) { + builder.polymorphic(VfxNodeSpec::class) { + all.forEach { type -> subclass(type) } + } + } + + @Suppress("UNCHECKED_CAST") + private fun PolymorphicModuleBuilder.subclass(type: VfxNodeType<*>) = subclass( + type.specClass as KClass, + type.serializer as KSerializer, + ) +} + +/** + * One kind of emitter module. How it is stored, what the editor calls it, and which nodes accept it. + */ +class VfxModuleType( + val id: String, + val specClass: KClass, + val serializer: KSerializer, + val titleKey: String, + val createDefault: (() -> S)? = null, + val appliesTo: (VfxEmitterSpec) -> Boolean = { true }, + val repeatable: Boolean = false, +) { + val key: ResourceLocation = id.rl +} + +object VfxModuleTypes { + val point = ExtensionPoints.create>("hollowengine:vfx/module_types".rl) + + init { + point.onChange(VfxModuleRevision::invalidate) + + register( + VfxModuleType( + id = "hollowengine:vfx/velocity_over_lifetime", + specClass = VfxVelocityOverLifetimeSpec::class, + serializer = VfxVelocityOverLifetimeSpec.serializer(), + titleKey = "hollowengine.gui.vfx.module_velocity_over_lifetime", + createDefault = { VfxVelocityOverLifetimeSpec() }, + ) + ) + register( + VfxModuleType( + id = "hollowengine:vfx/force", + specClass = VfxForceSpec::class, + serializer = VfxForceSpec.serializer(), + titleKey = "hollowengine.gui.vfx.module_force", + createDefault = { VfxForceSpec() }, + repeatable = true, + ) + ) + register( + VfxModuleType( + id = "hollowengine:vfx/noise", + specClass = VfxNoiseSpec::class, + serializer = VfxNoiseSpec.serializer(), + titleKey = "hollowengine.gui.vfx.module_noise", + createDefault = { VfxNoiseSpec() }, + ) + ) + register( + VfxModuleType( + id = "hollowengine:vfx/collision", + specClass = VfxCollisionSpec::class, + serializer = VfxCollisionSpec.serializer(), + titleKey = "hollowengine.gui.vfx.module_collision", + createDefault = { VfxCollisionSpec() }, + ) + ) + register( + VfxModuleType( + id = "hollowengine:vfx/uv_animation", + specClass = VfxUvAnimationSpec::class, + serializer = VfxUvAnimationSpec.serializer(), + titleKey = "hollowengine.gui.vfx.module_uv_animation", + createDefault = { VfxUvAnimationSpec() }, + ) + ) + } + + fun register(type: VfxModuleType<*>): ExtensionHandle = point.register(type.key, type) + + val all: List> get() = point.extensions + + fun of(spec: VfxModuleSpec): VfxModuleType<*>? = all.firstOrNull { it.specClass.isInstance(spec) } + + fun forEmitter(emitter: VfxEmitterSpec): List> = all.filter { it.appliesTo(emitter) } + + fun registerInto(builder: SerializersModuleBuilder) { + builder.polymorphic(VfxModuleSpec::class) { + all.forEach { type -> subclass(type) } + } + } + + @Suppress("UNCHECKED_CAST") + private fun PolymorphicModuleBuilder.subclass(type: VfxModuleType<*>) = subclass( + type.specClass as KClass, + type.serializer as KSerializer, + ) +} + +/** + * Says when the serializers module of [VfxFormat] has to be built again, which is whenever an addon + * registers a node or module kind. + */ +object VfxModuleRevision { + @Volatile + var current: Int = 0 + private set + + @Synchronized + fun invalidate() { + current++ + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxRendererSpecs.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxRendererSpecs.kt new file mode 100644 index 000000000..93374b893 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxRendererSpecs.kt @@ -0,0 +1,339 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatables.color +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatables.scalar + +/** + * A node drawn with a material. + */ +@Serializable +abstract class VfxSurfaceSpec : VfxNodeSpec() { + abstract val material: VfxMaterialSpec + + /** + * Multiplied into the color the surface already has, a particle's own included. A trail and a + * beam read it along their length, so a gradient on the lifetime fades them from head to tail. + */ + abstract val tint: VfxColorValue + + abstract fun withSurface( + material: VfxMaterialSpec = this.material, + tint: VfxColorValue = this.tint, + ): VfxSurfaceSpec + + override fun expressions(): List = tint.sources() + material.expressions() + + override fun animatables(): List = + listOf(color(VfxProperty.TINT, "tint", tint)) + material.uniforms.animatables() +} + +/** How a plane is turned towards the viewer. */ +@Serializable +enum class VfxFacing { + /** Keeps its rotation, so the plane sits in the world like a decal. */ + NONE, + + /** Always flat to the camera; the authored Z rotation still spins it. */ + CAMERA, + + /** Flat to the camera but locked to [VfxPlaneSpec.facingAxis]: smoke columns, beams, etc. */ + CAMERA_AXIS, + + /** Flat to the camera and stretched along the way the particle travels: sparks, rain. */ + VELOCITY, +} + +/** One textured quad, a unit square before its size or scale. */ +@Serializable +@SerialName("hollowengine:vfx/plane") +data class VfxPlaneSpec( + override val id: String = newVfxNodeId("plane"), + override val name: String = "Plane", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + override val material: VfxMaterialSpec = VfxMaterialSpec(), + override val tint: VfxColorValue = VfxColorValue.WHITE, + val facing: VfxFacing = VfxFacing.CAMERA, + val facingAxis: Vec3f = Vec3f.Y_AXIS, + override val particle: VfxAppearance = VfxAppearance(), +) : VfxSurfaceSpec(), VfxParticleRendererSpec { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withParticle(particle: VfxAppearance) = copy(particle = particle) + + override fun withSurface(material: VfxMaterialSpec, tint: VfxColorValue) = copy(material = material, tint = tint) + + override fun expressions() = super.expressions() + particle.expressions() + + override fun animatables() = super.animatables() + particle.animatables() +} + +/** Which built-in mesh a mesh node draws. */ +@Serializable +enum class VfxPrimitiveKind { CUBE, SPHERE, CYLINDER } + +/** A built-in mesh with its detail; equal primitives share one vertex buffer. */ +data class VfxPrimitive(val kind: VfxPrimitiveKind, val segments: Int = 0, val caps: Boolean = true) + +/** + * A built-in mesh, one block across before its size or scale, with normals and texture coordinates. + */ +@Serializable +abstract class VfxMeshSpec : VfxSurfaceSpec(), VfxParticleRendererSpec { + /** Turns the mesh along the way the particle travels, on top of its own rotation. */ + abstract val alignToVelocity: Boolean + + abstract val primitive: VfxPrimitive + + override fun expressions() = super.expressions() + particle.expressions() + + override fun animatables() = super.animatables() + particle.animatables() +} + +@Serializable +@SerialName("hollowengine:vfx/cube") +data class VfxCubeSpec( + override val id: String = newVfxNodeId("cube"), + override val name: String = "Cube", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + override val material: VfxMaterialSpec = VfxMaterialSpec(texture = WHITE_TEXTURE), + override val tint: VfxColorValue = VfxColorValue.WHITE, + override val alignToVelocity: Boolean = false, + override val particle: VfxAppearance = VfxAppearance(), +) : VfxMeshSpec() { + override val primitive get() = VfxPrimitive(VfxPrimitiveKind.CUBE) + + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withParticle(particle: VfxAppearance) = copy(particle = particle) + + override fun withSurface(material: VfxMaterialSpec, tint: VfxColorValue) = copy(material = material, tint = tint) +} + +@Serializable +@SerialName("hollowengine:vfx/sphere") +data class VfxSphereSpec( + override val id: String = newVfxNodeId("sphere"), + override val name: String = "Sphere", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + override val material: VfxMaterialSpec = VfxMaterialSpec(texture = WHITE_TEXTURE), + override val tint: VfxColorValue = VfxColorValue.WHITE, + override val alignToVelocity: Boolean = false, + /** Slices around the vertical axis; half as many rings go from pole to pole. */ + val segments: Int = 24, + override val particle: VfxAppearance = VfxAppearance(), +) : VfxMeshSpec() { + override val primitive get() = VfxPrimitive(VfxPrimitiveKind.SPHERE, segments.coerceIn(4, MAX_SEGMENTS)) + + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withParticle(particle: VfxAppearance) = copy(particle = particle) + + override fun withSurface(material: VfxMaterialSpec, tint: VfxColorValue) = copy(material = material, tint = tint) +} + +@Serializable +@SerialName("hollowengine:vfx/cylinder") +data class VfxCylinderSpec( + override val id: String = newVfxNodeId("cylinder"), + override val name: String = "Cylinder", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + override val material: VfxMaterialSpec = VfxMaterialSpec(texture = WHITE_TEXTURE), + override val tint: VfxColorValue = VfxColorValue.WHITE, + override val alignToVelocity: Boolean = false, + val segments: Int = 24, + /** Whether the ends are closed; an open tube is what shockwaves and portals are made of. */ + val caps: Boolean = true, + override val particle: VfxAppearance = VfxAppearance(), +) : VfxMeshSpec() { + override val primitive get() = VfxPrimitive(VfxPrimitiveKind.CYLINDER, segments.coerceIn(3, MAX_SEGMENTS), caps) + + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withParticle(particle: VfxAppearance) = copy(particle = particle) + + override fun withSurface(material: VfxMaterialSpec, tint: VfxColorValue) = copy(material = material, tint = tint) +} + +/** + * A model in its bind pose, drawn through the model renderer with its own materials. + */ +@Serializable +@SerialName("hollowengine:vfx/model") +data class VfxModelSpec( + override val id: String = newVfxNodeId("model"), + override val name: String = "Model", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + val model: String = "", + /** Turns the model along the way the particle travels, on top of its own rotation. */ + val alignToVelocity: Boolean = false, + val emissive: Boolean = false, + override val particle: VfxAppearance = VfxAppearance(), +) : VfxNodeSpec(), VfxParticleRendererSpec { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withParticle(particle: VfxAppearance) = copy(particle = particle) + + override fun expressions() = particle.expressions() + + override fun animatables() = particle.animatables() +} + +/** How a ribbon lays its texture along its length. */ +@Serializable +enum class VfxRibbonUv { + /** Once over the whole length, whatever it is. */ + STRETCH, + + /** Once every [VfxTrailSpec.tileLength] blocks. */ + TILE, +} + +/** + * A ribbon through the places its head has been: the node itself, or every particle when it sits + * under an emitter. + */ +@Serializable +@SerialName("hollowengine:vfx/trail") +data class VfxTrailSpec( + override val id: String = newVfxNodeId("trail"), + override val name: String = "Trail", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + override val material: VfxMaterialSpec = VfxMaterialSpec(texture = WHITE_TEXTURE, blend = VfxBlend.ADDITIVE), + override val tint: VfxColorValue = VfxColorValue.Gradient(VfxGradient.fade()), + /** + * Blocks across, read from head (`p.progress` 0) to tail (1). Under an emitter the other + * particle inputs are the particle's own, and its color multiplies the tint. + */ + val width: VfxValue = VfxValue.OverTime(VfxCurve.ramp(0.2f, 0f)), + /** Seconds a point of the trail stays. */ + val lifetime: Float = 0.5f, + /** How far the head moves before the trail takes a new point. */ + val minDistance: Float = 0.05f, + val maxPoints: Int = 32, + val uvMode: VfxRibbonUv = VfxRibbonUv.STRETCH, + val tileLength: Float = 1f, + /** Only the color is read: a trail has a width of its own and no turn. */ + override val particle: VfxAppearance = VfxAppearance(), +) : VfxSurfaceSpec(), VfxParticleRendererSpec { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withParticle(particle: VfxAppearance) = copy(particle = particle) + + override fun withSurface(material: VfxMaterialSpec, tint: VfxColorValue) = copy(material = material, tint = tint) + + override fun expressions() = super.expressions() + width.sources() + particle.color.sources() + + override fun animatables() = super.animatables() + listOf( + scalar(VfxProperty.WIDTH, "width", width), + color(VfxProperty.COLOR, "color", particle.color), + ) +} + +/** + * A ribbon from the node to another node, or to a point of its own, bent by noise: lasers, + * lightning, tethers. + */ +@Serializable +@SerialName("hollowengine:vfx/beam") +data class VfxBeamSpec( + override val id: String = newVfxNodeId("beam"), + override val name: String = "Beam", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + override val material: VfxMaterialSpec = VfxMaterialSpec(texture = WHITE_TEXTURE, blend = VfxBlend.ADDITIVE), + override val tint: VfxColorValue = VfxColorValue.WHITE, + /** The node the beam ends at; empty ends it at [end]. */ + val target: String = "", + /** The end point in the space of this node, when there is no [target]. */ + val end: Vec3f = Vec3f(0f, 2f, 0f), + /** Blocks across, read from start (`p.progress` 0) to end (1). */ + val width: VfxValue = VfxValue.Const(0.15f), + val segments: Int = 24, + /** How far, in blocks, noise pushes the middle of the beam aside; the ends stay put. */ + val noise: VfxValue = VfxValue.ZERO, + /** Noise waves per block of beam. */ + val noiseFrequency: Float = 1.5f, + /** How fast the noise changes, in cycles per second. */ + val noiseSpeed: Float = 4f, + /** Texture lengths per second the texture slides from start to end. */ + val uvScroll: Float = 0f, + val uvMode: VfxRibbonUv = VfxRibbonUv.STRETCH, + val tileLength: Float = 1f, +) : VfxSurfaceSpec() { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun withSurface(material: VfxMaterialSpec, tint: VfxColorValue) = copy(material = material, tint = tint) + + override fun expressions() = super.expressions() + width.sources() + noise.sources() + + override fun animatables() = super.animatables() + listOf( + scalar(VfxProperty.WIDTH, "width", width), + scalar(VfxProperty.BEAM_NOISE, "beam_noise", noise), + VfxAnimatable(VfxProperty.BEAM_END, VfxAnimatables.key("beam_end"), VfxAnimatableKind.VEC3) { + floatArrayOf(end.x, end.y, end.z) + }, + ) +} + +/** A plain white texture, for surfaces whose look comes from the tint or a shader. */ +const val WHITE_TEXTURE = "hollowengine:textures/particle/white.png" + +private const val MAX_SEGMENTS = 128 diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxScreenSpecs.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxScreenSpecs.kt new file mode 100644 index 000000000..fbafb8804 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxScreenSpecs.kt @@ -0,0 +1,105 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatables.scalar + +/** + * A full-screen pass over the frame while the node is on: a core shader drawn over the whole screen, + * reading the frame so far as `SceneColor` and its depth as `SceneDepth`. + * + * How strong it is, and whether it fades with distance, is up to the uniforms the author declares: + * an expression such as `1 - n.camera_distance / 32` makes it weaker the farther away the node is. + */ +@Serializable +@SerialName("hollowengine:vfx/post_effect") +data class VfxPostEffectSpec( + override val id: String = newVfxNodeId("post"), + override val name: String = "Post Effect", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + /** `namespace:path` of `assets/namespace/shaders/core/path.json`. */ + val shader: String = "hollowengine:vfx/post/grayscale", + val uniforms: List = listOf(VfxUniformSpec("Strength", VfxUniformValue.Scalar(VfxValue.ONE))), + val samplers: List = emptyList(), +) : VfxNodeSpec() { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun expressions(): List = uniforms.flatMap { it.value.sources() } + + override fun animatables(): List = uniforms.animatables() +} + +/** + * Shakes the camera of whoever sees the effect while the node is on. + * + * [strength] scales everything and is the place for a distance falloff, such as + * `1 - n.camera_distance / 24`. + */ +@Serializable +@SerialName("hollowengine:vfx/camera_shake") +data class VfxCameraShakeSpec( + override val id: String = newVfxNodeId("shake"), + override val name: String = "Camera Shake", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform.IDENTITY, + override val children: List = emptyList(), + val strength: VfxValue = VfxValue.ONE, + /** Shakes per second. */ + val frequency: VfxValue = VfxValue.Const(14f), + /** The widest swing in degrees: pitch, yaw and roll. */ + val amplitude: Vec3f = Vec3f(1.5f, 1.5f, 0.75f), +) : VfxNodeSpec() { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun expressions(): List = strength.sources() + frequency.sources() + + override fun animatables(): List = listOf( + scalar(VfxProperty.SHAKE_STRENGTH, "shake_strength", strength), + scalar(VfxProperty.SHAKE_FREQUENCY, "shake_frequency", frequency), + ) +} + +/** + * Paints the sky while the node is on: a core shader drawn on the far plane, so it covers only what + * nothing else stands in front of. Terrain, entities and clouds stay over it. + */ +@Serializable +@SerialName("hollowengine:vfx/sky") +data class VfxSkySpec( + override val id: String = newVfxNodeId("sky"), + override val name: String = "Sky", + override val enabled: Boolean = true, + override val transform: VfxTransform = VfxTransform(position = Vec3f(0f, 200f, 0f)), + override val children: List = emptyList(), + /** `namespace:path` of `assets/namespace/shaders/core/path.json`. */ + val shader: String = "hollowengine:vfx/sky/shockwave", + val uniforms: List = emptyList(), + val samplers: List = emptyList(), +) : VfxNodeSpec() { + override fun withCommon( + id: String, + name: String, + enabled: Boolean, + transform: VfxTransform, + children: List, + ) = copy(id = id, name = name, enabled = enabled, transform = transform, children = children) + + override fun expressions(): List = uniforms.flatMap { it.value.sources() } + + override fun animatables(): List = uniforms.animatables() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxValue.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxValue.kt new file mode 100644 index 000000000..c58eb3bfb --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/VfxValue.kt @@ -0,0 +1,290 @@ +package ru.hollowhorizon.hollowengine.common.vfx + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.utils.Color +import kotlin.math.abs + +/** + * What a number in an effect may be driven by. + */ +@Serializable +sealed interface VfxValue { + /** One number, and the only shape that folds away entirely at load time. */ + @Serializable + @SerialName("const") + data class Const(val value: Float = 0f) : VfxValue + + /** A number picked uniformly between two bounds, once per evaluation. */ + @Serializable + @SerialName("range") + data class Range(val from: Float = 0f, val to: Float = 1f) : VfxValue + + /** A curve read at [input], scaled by [scale]. */ + @Serializable + @SerialName("curve") + data class OverTime( + val curve: VfxCurve = VfxCurve(), + val scale: Float = 1f, + val input: VfxCurveInput = VfxCurveInput.LIFETIME, + ) : VfxValue + + /** An expression in the VFX dialect, such as `p.speed * 0.5` or `d.charge`. */ + @Serializable + @SerialName("expr") + data class Expr(val source: String = "") : VfxValue + + companion object { + val ZERO = Const(0f) + val ONE = Const(1f) + + fun of(value: Float): VfxValue = when (value) { + 0f -> ZERO + 1f -> ONE + else -> Const(value) + } + } +} + +/** + * What the horizontal axis of a curve or a gradient reads. + */ +@Serializable +enum class VfxCurveInput { + /** + * Age over lifetime, 0 at spawn and 1 as the particle dies. + */ + LIFETIME, + + /** Age in seconds, for effects that should not stretch with lifetime. */ + PARTICLE_AGE, + + /** Seconds since the emitter started its current loop. */ + EMITTER_AGE, + + /** Seconds since the effect began playing. */ + EFFECT_TIME, + + /** Speed in blocks per second, for "faster means bigger" curves. */ + SPEED, + + /** The particle's own random number, 0 to 1: a pick along the curve that holds for its whole life. */ + RANDOM, + + /** The random number of the particle a sub-emitter spawned this one from; 0 elsewhere. */ + PARENT_RANDOM, +} + +/** How the segment starting at a key reaches the next one; mirrors the timeline editor modes. */ +@Serializable +enum class VfxInterpolation { + CONSTANT, LINEAR, BEZIER, +} + +/** + * One key of a [VfxCurve]. Tangents are handle offsets in curve space, time and value, exactly as + * the timeline editor stores them. + */ +@Serializable +data class VfxKey( + val time: Float = 0f, + val value: Float = 0f, + val interpolation: VfxInterpolation = VfxInterpolation.BEZIER, + @SerialName("in_time") val inTime: Float = 0f, + @SerialName("in_value") val inValue: Float = 0f, + @SerialName("out_time") val outTime: Float = 0f, + @SerialName("out_value") val outValue: Float = 0f, +) + +/** + * A sampled curve. + */ +@Serializable +data class VfxCurve(val keys: List = emptyList()) { + /** The value at [time]; flat before the first key and after the last, [fallback] when empty. */ + fun valueAt(time: Float, fallback: Float = 0f): Float { + if (keys.isEmpty()) return fallback + val first = keys.first() + if (keys.size == 1 || time <= first.time) return first.value + val last = keys.last() + if (time >= last.time) return last.value + + var index = 0 + while (index < keys.lastIndex && keys[index + 1].time <= time) index++ + val start = keys[index] + val end = keys[index + 1] + val span = end.time - start.time + if (span <= EPSILON) return end.value + + return when (start.interpolation) { + VfxInterpolation.CONSTANT -> start.value + VfxInterpolation.LINEAR -> start.value + (end.value - start.value) * ((time - start.time) / span) + VfxInterpolation.BEZIER -> bezier(start, end, span, time) + } + } + + private fun bezier(start: VfxKey, end: VfxKey, span: Float, time: Float): Float { + val outLength = start.outTime.coerceAtLeast(0f) + val inLength = (-end.inTime).coerceAtLeast(0f) + val total = outLength + inLength + val scale = if (total > span) span / total else 1f + + val p1 = start.value + start.outValue * scale + val p2 = end.value + end.inValue * scale + val x1 = start.time + outLength * scale + val x2 = end.time - inLength * scale + + return cubic(start.value, p1, p2, end.value, solve(start.time, x1, x2, end.time, time)) + } + + private fun solve(x0: Float, x1: Float, x2: Float, x3: Float, time: Float): Float { + var s = ((time - x0) / (x3 - x0)).coerceIn(0f, 1f) + repeat(NEWTON_STEPS) { + val error = cubic(x0, x1, x2, x3, s) - time + if (abs(error) <= EPSILON) return s + val slope = slope(x0, x1, x2, x3, s) + if (abs(slope) <= EPSILON) return@repeat + s = (s - error / slope).coerceIn(0f, 1f) + } + var low = 0f + var high = 1f + repeat(BISECTION_STEPS) { + s = (low + high) * 0.5f + if (cubic(x0, x1, x2, x3, s) < time) low = s else high = s + } + return s + } + + private fun cubic(a: Float, b: Float, c: Float, d: Float, s: Float): Float { + val inverse = 1f - s + return inverse * inverse * inverse * a + 3f * inverse * inverse * s * b + 3f * inverse * s * s * c + s * s * s * d + } + + private fun slope(a: Float, b: Float, c: Float, d: Float, s: Float): Float { + val inverse = 1f - s + return 3f * inverse * inverse * (b - a) + 6f * inverse * s * (c - b) + 3f * s * s * (d - c) + } + + companion object { + private const val EPSILON = 1.0e-5f + private const val NEWTON_STEPS = 8 + private const val BISECTION_STEPS = 24 + + /** A curve that holds [value] for its whole span. */ + fun flat(value: Float): VfxCurve = VfxCurve(listOf(VfxKey(0f, value), VfxKey(1f, value))) + + fun ramp(from: Float, to: Float): VfxCurve = VfxCurve(listOf(VfxKey(0f, from), VfxKey(1f, to))) + } +} + +/** A color as the file stores it. */ +@Serializable +data class VfxRgba(val r: Float = 1f, val g: Float = 1f, val b: Float = 1f, val a: Float = 1f) { + + companion object { + val WHITE = VfxRgba() + + fun of(color: Color) = VfxRgba(color.r, color.g, color.b, color.a) + } +} + +@Serializable +data class VfxGradientStop(val position: Float = 0f, val color: VfxRgba = VfxRgba.WHITE) + +/** Color over a normalized axis. Stops are kept sorted by the editor and sampled linearly. */ +@Serializable +data class VfxGradient(val stops: List = emptyList()) { + fun colorAt(position: Float, fallback: VfxRgba = VfxRgba.WHITE): VfxRgba { + if (stops.isEmpty()) return fallback + val first = stops.first() + if (stops.size == 1 || position <= first.position) return first.color + val last = stops.last() + if (position >= last.position) return last.color + + var index = 0 + while (index < stops.lastIndex && stops[index + 1].position <= position) index++ + val start = stops[index] + val end = stops[index + 1] + val span = end.position - start.position + if (span <= 1.0e-5f) return end.color + + val t = (position - start.position) / span + return VfxRgba( + start.color.r + (end.color.r - start.color.r) * t, + start.color.g + (end.color.g - start.color.g) * t, + start.color.b + (end.color.b - start.color.b) * t, + start.color.a + (end.color.a - start.color.a) * t, + ) + } + + companion object { + fun fade(color: VfxRgba = VfxRgba.WHITE) = VfxGradient( + listOf( + VfxGradientStop(0f, color), + VfxGradientStop(1f, color.copy(a = 0f)), + ) + ) + } +} + +/** Three [VfxValue]s that together make a vector: an offset, a velocity, a rotation, a size. */ +@Serializable +data class VfxVec3Value( + val x: VfxValue = VfxValue.ZERO, + val y: VfxValue = VfxValue.ZERO, + val z: VfxValue = VfxValue.ZERO, +) { + companion object { + val ZERO = VfxVec3Value() + val ONE = VfxVec3Value(VfxValue.ONE, VfxValue.ONE, VfxValue.ONE) + + fun of(x: Float, y: Float, z: Float) = VfxVec3Value(VfxValue.of(x), VfxValue.of(y), VfxValue.of(z)) + + fun all(value: Float) = of(value, value, value) + } +} + +/** How a color property is authored. */ +@Serializable +sealed interface VfxColorValue { + @Serializable + @SerialName("solid") + data class Solid(val color: VfxRgba = VfxRgba.WHITE) : VfxColorValue + + @Serializable + @SerialName("gradient") + data class Gradient( + val gradient: VfxGradient = VfxGradient.fade(), + val input: VfxCurveInput = VfxCurveInput.LIFETIME, + ) : VfxColorValue + + /** Per-channel values, for colors that follow something the other shapes cannot reach. */ + @Serializable + @SerialName("channels") + data class Channels( + val r: VfxValue = VfxValue.ONE, + val g: VfxValue = VfxValue.ONE, + val b: VfxValue = VfxValue.ONE, + val a: VfxValue = VfxValue.ONE, + ) : VfxColorValue + + companion object { + val WHITE = Solid() + } +} + +fun VfxValue.constantOr(fallback: Float): Float = when (this) { + is VfxValue.Const -> value + is VfxValue.Range -> (from + to) * 0.5f + is VfxValue.OverTime -> curve.valueAt(0f, fallback) * scale + is VfxValue.Expr -> fallback +} + +fun VfxVec3Value.constants(fallback: Float = 0f): FloatArray = + floatArrayOf(x.constantOr(fallback), y.constantOr(fallback), z.constantOr(fallback)) + +fun VfxColorValue.constants(): FloatArray = when (this) { + is VfxColorValue.Solid -> floatArrayOf(color.r, color.g, color.b, color.a) + is VfxColorValue.Gradient -> gradient.colorAt(0f).let { floatArrayOf(it.r, it.g, it.b, it.a) } + is VfxColorValue.Channels -> floatArrayOf(r.constantOr(1f), g.constantOr(1f), b.constantOr(1f), a.constantOr(1f)) +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxCollisionSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxCollisionSpec.kt new file mode 100644 index 000000000..c6906bbe9 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxCollisionSpec.kt @@ -0,0 +1,31 @@ +package ru.hollowhorizon.hollowengine.common.vfx.modules + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("hollowengine:vfx/collision") +data class VfxCollisionSpec( + override val id: String = "collision", + override val enabled: Boolean = true, + val action: VfxCollisionAction = VfxCollisionAction.BOUNCE, + /** How much speed survives a bounce, 0 to 1. */ + val bounce: Float = 0.4f, + /** How much of the sideways speed is kept after touching a surface, 0 to 1. */ + val friction: Float = 0.8f, + /** The particle is treated as a sphere of this radius, in blocks. */ + val radius: Float = 0.05f, + /** Seconds taken off the remaining lifetime on every hit. */ + val lifetimeLoss: Float = 0f, +) : VfxModuleSpec() { + override fun withEnabled(enabled: Boolean) = copy(enabled = enabled) +} + +/** What happens when a particle meets a block. */ +@Serializable +enum class VfxCollisionAction { + DIE, BOUNCE, + + /** Keeps the movement along the surface and drops the part going into it. */ + SLIDE, +} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxForceSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxForceSpec.kt new file mode 100644 index 000000000..0923a0861 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxForceSpec.kt @@ -0,0 +1,54 @@ +package ru.hollowhorizon.hollowengine.common.vfx.modules + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.* + +/** + * A force acting on the particles of this emitter, placed in the emitter frame. + */ +@Serializable +@SerialName("hollowengine:vfx/force") +data class VfxForceSpec( + override val id: String = newVfxNodeId("force"), + override val enabled: Boolean = true, + val kind: VfxForceKind = VfxForceKind.DIRECTIONAL, + /** Blocks per second squared, or the fraction of speed lost per second for [VfxForceKind.DRAG]. */ + val strength: VfxValue = VfxValue.Const(1f), + val direction: Vec3f = Vec3f.Y_AXIS, + val center: Vec3f = Vec3f.ZERO, + /** Blocks; zero reaches everywhere. */ + val radius: VfxValue = VfxValue.ZERO, + /** Whether the push fades to nothing at the edge of [radius]. */ + val falloff: Boolean = true, +) : VfxModuleSpec() { + override fun withEnabled(enabled: Boolean) = copy(enabled = enabled) + + override fun expressions(): List = strength.sources() + radius.sources() + + override fun animatables() = listOf( + VfxModuleAnimatable("strength", "hollowengine.gui.vfx.strength", VfxAnimatableKind.FLOAT) { + floatArrayOf((it as VfxForceSpec).strength.constantOr(0f)) + }, + VfxModuleAnimatable("radius", "hollowengine.gui.vfx.radius", VfxAnimatableKind.FLOAT) { + floatArrayOf((it as VfxForceSpec).radius.constantOr(0f)) + }, + ) +} + +/** What a force does to the particles of its emitter. */ +@Serializable +enum class VfxForceKind { + /** Pushes along [VfxForceSpec.direction], i.e. wind. */ + DIRECTIONAL, + + /** Pushes away from [VfxForceSpec.center], or pulls in when the strength is negative. */ + POINT, + + /** Spins around [VfxForceSpec.direction] through [VfxForceSpec.center]. */ + VORTEX, + + /** Slows whatever is inside its radius. */ + DRAG, +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxModules.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxModules.kt new file mode 100644 index 000000000..59c1ee9fc --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxModules.kt @@ -0,0 +1,30 @@ +package ru.hollowhorizon.hollowengine.common.vfx.modules + +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatableKind + +/** + * One behavior attached to emitter. + */ +@Serializable +abstract class VfxModuleSpec { + abstract val id: String + abstract val enabled: Boolean + + abstract fun withEnabled(enabled: Boolean): VfxModuleSpec + + open fun expressions(): List = emptyList() + + /** + * The numbers of this module the timeline can drive, by field name. + */ + open fun animatables(): List = emptyList() +} + +/** One number of a module that a timeline track can drive. */ +class VfxModuleAnimatable( + val field: String, + val titleKey: String, + val kind: VfxAnimatableKind, + val read: (VfxModuleSpec) -> FloatArray, +) \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxNoiseSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxNoiseSpec.kt new file mode 100644 index 000000000..8f3e10d3b --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxNoiseSpec.kt @@ -0,0 +1,39 @@ +package ru.hollowhorizon.hollowengine.common.vfx.modules + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatableKind +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.constantOr +import ru.hollowhorizon.hollowengine.common.vfx.sources + +/** + * Smooth pseudo-random motion, the cheapest way to make smoke and magic look alive. + */ +@Serializable +@SerialName("hollowengine:vfx/noise") +data class VfxNoiseSpec( + override val id: String = "noise", + override val enabled: Boolean = true, + /** Blocks per second squared. */ + val strength: VfxValue = VfxValue.Const(1f), + /** Field cells per block: higher means finer, more chaotic motion. */ + val frequency: VfxValue = VfxValue.Const(0.5f), + /** How fast the field itself moves, in field units per second. */ + val scrollSpeed: Float = 0.25f, + /** Extra layers of finer detail. Each one doubles the cost. */ + val octaves: Int = 1, +) : VfxModuleSpec() { + override fun withEnabled(enabled: Boolean) = copy(enabled = enabled) + + override fun expressions(): List = strength.sources() + frequency.sources() + + override fun animatables() = listOf( + VfxModuleAnimatable("strength", "hollowengine.gui.vfx.strength", VfxAnimatableKind.FLOAT) { + floatArrayOf((it as VfxNoiseSpec).strength.constantOr(0f)) + }, + VfxModuleAnimatable("frequency", "hollowengine.gui.vfx.frequency", VfxAnimatableKind.FLOAT) { + floatArrayOf((it as VfxNoiseSpec).frequency.constantOr(0.5f)) + }, + ) +} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxUvAnimationSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxUvAnimationSpec.kt new file mode 100644 index 000000000..43dcbc5b6 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxUvAnimationSpec.kt @@ -0,0 +1,43 @@ +package ru.hollowhorizon.hollowengine.common.vfx.modules + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Plays a flipbook, the material region cut into a grid of frames. + */ +@Serializable +@SerialName("hollowengine:vfx/uv_animation") +data class VfxUvAnimationSpec( + override val id: String = "uv_animation", + override val enabled: Boolean = true, + val columns: Int = 4, + val rows: Int = 4, + /** How many cells hold a frame, counted row by row; zero means every cell. */ + val frames: Int = 0, + val mode: VfxUvMode = VfxUvMode.OVER_LIFETIME, + val fps: Float = 12f, + /** How many times the sheet is walked over the lifetime; only for [VfxUvMode.OVER_LIFETIME]. */ + val cycles: Float = 1f, +) : VfxModuleSpec() { + override fun withEnabled(enabled: Boolean) = copy(enabled = enabled) + + val frameCount: Int + get() { + val cells = columns.coerceAtLeast(1) * rows.coerceAtLeast(1) + return if (frames <= 0) cells else frames.coerceAtMost(cells) + } +} + +/** How the frames of a sprite sheet are chosen. */ +@Serializable +enum class VfxUvMode { + /** Walks the sheet once across the particle lifetime. */ + OVER_LIFETIME, + + /** Walks the sheet at [VfxUvAnimationSpec.fps], looping. */ + FPS, + + /** One frame, picked when the particle is born and kept. */ + RANDOM_FRAME, +} \ No newline at end of file diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxVelocityOverLifetimeSpec.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxVelocityOverLifetimeSpec.kt new file mode 100644 index 000000000..a6356f406 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/vfx/modules/VfxVelocityOverLifetimeSpec.kt @@ -0,0 +1,31 @@ +package ru.hollowhorizon.hollowengine.common.vfx.modules + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.hollowhorizon.hollowengine.common.vfx.VfxAnimatableKind +import ru.hollowhorizon.hollowengine.common.vfx.VfxSimulationSpace +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value +import ru.hollowhorizon.hollowengine.common.vfx.constants +import ru.hollowhorizon.hollowengine.common.vfx.sources + +/** + * An extra velocity added while integrating, on top of the particle own. + */ +@Serializable +@SerialName("hollowengine:vfx/velocity_over_lifetime") +data class VfxVelocityOverLifetimeSpec( + override val id: String = "velocity_over_lifetime", + override val enabled: Boolean = true, + val velocity: VfxVec3Value = VfxVec3Value.ZERO, + val space: VfxSimulationSpace = VfxSimulationSpace.LOCAL, +) : VfxModuleSpec() { + override fun withEnabled(enabled: Boolean) = copy(enabled = enabled) + + override fun expressions(): List = velocity.sources() + + override fun animatables() = listOf( + VfxModuleAnimatable("velocity", "hollowengine.gui.vfx.velocity", VfxAnimatableKind.VEC3) { + (it as VfxVelocityOverLifetimeSpec).velocity.constants() + }, + ) +} \ No newline at end of file diff --git a/runtime/src/main/lang/en_us/hollowengine/commands.json b/runtime/src/main/lang/en_us/hollowengine/commands.json index 5e51ab4ab..e85f6988f 100644 --- a/runtime/src/main/lang/en_us/hollowengine/commands.json +++ b/runtime/src/main/lang/en_us/hollowengine/commands.json @@ -1,7 +1,10 @@ { "copy": "§6[§bHollow Engine§6] §7Value: %s copied.", "model_animations": "§6[§bHollow Engine§6] Model %s animations:", - "model_textures": "§6[§bHollow Engine§6] Model %s textures:", + "model_materials": "§6[§bHollow Engine§6] Model %s materials:", + "model_unreadable": "Model %s could not be read: check the path and the file", + "entity_without_model": "%s has no model component; it shows its vanilla model", + "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", 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 eb948e7f3..73bbcadca 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 @@ -1,18 +1,13 @@ { - "layers": "Layers", - "layers_empty": "Empty. Add a layer below.", - "add_layer": "+ Add", + "add_layer": "Add layer", "kind_controller": "Controller", "kind_clip": "Clip", "kind_procedural": "Procedural", - "show_layers": "Show layers", - "hide_layers": "Hide layers", "reset_view": "Reset view", "parameters": "Parameters", "no_graph": "This layer has no graph; its parameters are in the inspector.", "no_layers": "No layers", "nothing_selected": "Nothing selected", - "pick_controller": "Select a controller layer", "layer_removed": "Layer removed", "state_removed": "State removed", "transition_removed": "Transition removed", @@ -61,5 +56,7 @@ "exit_time": "Exit time", "condition": "Condition", "any_state": "any state", - "always": "always" + "always": "always", + "delete_layer": "Delete layer", + "no_layers_hint": "Layers live in the Scene window: right-click there to add one." } 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 92587749e..f05db6436 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/console.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/console.json @@ -11,5 +11,7 @@ "auto_scroll": "Auto-scroll", "empty": "No log messages match the current filter", "mode.command": "Command", - "mode.kotlin": "Kotlin" + "mode.kotlin": "Kotlin", + "tab.logs": "Logs", + "tab.problems": "Problems" } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/docking.json b/runtime/src/main/lang/en_us/hollowengine/gui/docking.json index ef6176bc6..fe9299e87 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/docking.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/docking.json @@ -1,6 +1,15 @@ { "pin_left": "Pin Left", "pin_right": "Pin Right", + "pin_left_split": "Pin Left, Under the Panel", + "pin_right_split": "Pin Right, Under the Panel", + "pin_left_bottom": "Pin Bottom Left", + "pin_right_bottom": "Pin Bottom Right", + "move_to_top": "Move to the Side Panel", + "move_to_split": "Move Under the Side Panel", + "move_to_bottom": "Move to the Bottom Area", + "swap_panels": "Swap with the Other Panel", + "park_here": "Pin Here", "undock": "Unpin", "close": "Close", "hide_stripes": "Hide Side Bars", diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json index ebbde2874..9a21085f4 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/cutscene.json @@ -58,24 +58,26 @@ "handles.auto_hint": "Derived from the neighbouring keys. Drag a handle in the curve view to take over; hold Shift to keep the opposite length, Ctrl to move one handle alone.", "handles.incoming": "in", "handles.outgoing": "out", - "layer.settings": "Layer", - "property.settings": "Property", - "layer.weight": "Weight", - "layer.visible": "Visible", - "layer.locked": "Locked", - "layer.delete": "Delete layer", - "layer.blend_mode": "Blending", - "blend.override": "Override", - "blend.add": "Add", - "blend.subtract": "Subtract", - "blend.multiply": "Multiply", "rotation.mode": "Rotation basis", "rotation.euler": "Euler", "rotation.quaternion": "Quaternion", - "rotation.mode_hint": "Switching resamples the curves onto the new channels; handles are reset.", - "limits.title": "Limits", - "limits.hint": "Set by the property type; the editor keeps keys inside them.", "weather.clear": "Clear", "weather.rain": "Rain", - "weather.thunder": "Thunder" + "weather.thunder": "Thunder", + "track.select_keys": "Select its keys", + "track.show": "Show", + "track.hide": "Hide", + "track.lock": "Lock", + "track.unlock": "Unlock", + "track.remove": "Take off the list", + "action.to_start": "To the start (Home)", + "action.to_end": "To the end", + "action.play": "Play or pause (Space)", + "action.prev_key": "Previous key (Down)", + "action.next_key": "Next key (Up)", + "action.zoom_in": "Zoom in (Ctrl+wheel)", + "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" } 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 22353beda..60777137b 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 @@ -13,5 +13,8 @@ "status.definition_not_found": "Definition not found", "status.resource_not_found": "Cannot find '%s'", "status.unsupported_action": "This action is not supported", - "status.unsupported_definition": "Cannot open the definition in %s" + "status.unsupported_definition": "Cannot open the definition in %s", + "completion.navigate": "to navigate,", + "completion.or": "or", + "completion.insert": "to insert" } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/problems.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/problems.json new file mode 100644 index 000000000..ebe71d18f --- /dev/null +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/problems.json @@ -0,0 +1,7 @@ +{ + "show": "Show in Problems", + "no_file": "Open a file to see its problems", + "none": "No problems found", + "copy": "Copy this message", + "copy_all": "Copy all" +} diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/status.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/status.json new file mode 100644 index 000000000..72cb0b83e --- /dev/null +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/status.json @@ -0,0 +1,5 @@ +{ + "resources": "Game resources", + "read_only": "Read-only", + "caret": "Line %s, column %s" +} diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/ide/windows.json b/runtime/src/main/lang/en_us/hollowengine/gui/ide/windows.json index 3f283a902..be43a2b62 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/ide/windows.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/ide/windows.json @@ -1,6 +1,9 @@ { - "cutscene_timeline": "Cutscene Timeline", + "timeline": "Timeline", + "scene": "Scene", + "scene_empty": "Open something with a hierarchy to see it here.", "inspector": "Inspector", "game_viewport": "Game View", - "game_viewport.detached": "The game is only shown while this panel is docked" + "game_viewport.detached": "The game is only shown while this panel is docked", + "scene_add_hint": "Right-click to add one." } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/inspector.json b/runtime/src/main/lang/en_us/hollowengine/gui/inspector.json index ad6e5f697..2b5d2bda6 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/inspector.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/inspector.json @@ -1,13 +1,18 @@ { "add": "Add", + "asset_missing": "No such file in the loaded packs: %s", + "asset_use": "Use %s", "bone.clear": "Leave it unset", "bone.no_matches": "No bone by that name.", "bone.none": "not set", "bone.search": "Find a bone", + "close": "Close", "empty_list": "Empty", "not_set": "Not set", + "nothing_found": "Nothing found", "nothing_selected": "Select something to see its parameters", "pick": "Pick from loaded assets", "remove": "Remove", + "search": "Search", "unsupported": "%s cannot be edited here" } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/rig_editor.json b/runtime/src/main/lang/en_us/hollowengine/gui/rig_editor.json index e88763b2e..81996de86 100644 --- a/runtime/src/main/lang/en_us/hollowengine/gui/rig_editor.json +++ b/runtime/src/main/lang/en_us/hollowengine/gui/rig_editor.json @@ -16,5 +16,6 @@ "toggle_colliders": "Show the colliders", "start_physics": "Drop the rig and see what it does", "stop_physics": "Stop the simulation", - "kind_item": "Item" + "kind_item": "Item", + "kind_vfx": "Effect" } diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json b/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json new file mode 100644 index 000000000..7e675406f --- /dev/null +++ b/runtime/src/main/lang/en_us/hollowengine/gui/shadergraph.json @@ -0,0 +1,127 @@ +{ + "graph": "Material", + "category.input": "Input", + "category.math": "Math", + "category.vector": "Vector", + "category.normal": "Normals", + "category.uv": "UV", + "category.texture": "Texture", + "category.procedural": "Procedural", + "preview": "Preview", + "show_preview": "Show preview", + "preview_mesh": "Shown on", + "preview_rotate": "Turn the mesh", + "preview_texture": "Main texture in previews", + "mesh.quad": "Quad", + "mesh.sphere": "Sphere", + "mesh.cube": "Cube", + "pick_property": "Pick…", + "option.property": "Property", + "option.mask": "Mask", + "option.flip_x": "Flip horizontally", + "option.flip_y": "Flip vertically", + "option.expression": "Expression", + "option.type": "Type", + "option.type.auto": "From the expression", + "option.shape": "Shape", + "option.shape.ellipse": "Ellipse", + "option.shape.rectangle": "Rectangle", + "option.shape.polygon": "Polygon", + "option.shape.ring": "Ring", + "expression.empty": "The expression is empty", + "expression.unexpected_character": "This character is not allowed here", + "expression.unexpected_token": "Unexpected", + "expression.expected_value": "A value was expected", + "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", + "inputs": "Inputs", + "options": "Options", + "property": "Property", + "no_properties": "The graph has no properties yet: add one with nothing selected.", + "properties": "Properties", + "add_property": "Add property", + "remove_property": "Remove property", + "texture": "Texture", + "problems": "Problems", + "no_problems": "The graph compiles.", + "problem.unknown_node": "Unknown node", + "problem.unknown_pin": "Unknown pin", + "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", + "problem.type_mismatch": "This link cannot carry that type", + "problem.unresolved_type": "The type of this output is unknown; pick a property", + "problem.fragment_only_in_vertex": "Only works per pixel, but the vertex offset needs it", + "problem.unavailable_input": "Not available here", + "problem.invalid_expression": "The expression is wrong", + "problem.glsl_error": "The shader did not compile", + "node.input.uv": "UV", + "node.input.texture_uv": "Texture UV", + "node.input.position": "Position", + "node.input.object_position": "Local Position", + "node.input.normal": "Normal", + "node.input.view_direction": "View Direction", + "node.input.screen_uv": "Screen UV", + "node.input.vertex_color": "Particle Color", + "node.input.time": "Time", + "node.input.scene_depth": "Scene Depth", + "node.input.scene_color": "Scene Color", + "node.input.main_texture": "Main Texture", + "node.input.property": "Property", + "node.input.float": "Float", + "node.input.vector2": "Vector 2", + "node.input.vector3": "Vector 3", + "node.input.vector4": "Vector 4", + "node.input.color": "Color", + "node.math.add": "Add", + "node.math.subtract": "Subtract", + "node.math.multiply": "Multiply", + "node.math.divide": "Divide", + "node.math.power": "Power", + "node.math.minimum": "Minimum", + "node.math.maximum": "Maximum", + "node.math.one_minus": "One Minus", + "node.math.negate": "Negate", + "node.math.absolute": "Absolute", + "node.math.fraction": "Fraction", + "node.math.floor": "Floor", + "node.math.sine": "Sine", + "node.math.cosine": "Cosine", + "node.math.square_root": "Square Root", + "node.math.saturate": "Saturate", + "node.math.clamp": "Clamp", + "node.math.lerp": "Lerp", + "node.math.step": "Step", + "node.math.smoothstep": "Smoothstep", + "node.math.remap": "Remap", + "node.math.modulo": "Modulo", + "node.math.expression": "Expression", + "node.vector.length": "Length", + "node.vector.normalize": "Normalize", + "node.vector.dot": "Dot Product", + "node.vector.distance": "Distance", + "node.vector.cross": "Cross Product", + "node.vector.split": "Split", + "node.vector.combine": "Combine", + "node.vector.swizzle": "Swizzle", + "node.uv.tiling_offset": "Tiling and Offset", + "node.uv.rotate": "Rotate", + "node.uv.polar": "Polar Coordinates", + "node.uv.scroll": "Scroll", + "node.texture.sample": "Sample Texture", + "node.procedural.value_noise": "Value Noise", + "node.procedural.gradient_noise": "Gradient Noise", + "node.procedural.fbm": "Fractal Noise", + "node.procedural.voronoi": "Voronoi", + "node.procedural.shape": "Shape", + "node.normal.offset": "Offset Along Normal", + "node.normal.bump": "Bump", + "node.normal.surface": "Surface Normal", + "node.normal.lambert": "Lambert Light", + "node.procedural.fresnel": "Fresnel", + "node.output.surface": "Surface Output" +} diff --git a/runtime/src/main/lang/en_us/hollowengine/gui/vfx.json b/runtime/src/main/lang/en_us/hollowengine/gui/vfx.json new file mode 100644 index 000000000..ebd284684 --- /dev/null +++ b/runtime/src/main/lang/en_us/hollowengine/gui/vfx.json @@ -0,0 +1,316 @@ +{ + "effect": "Effect", + "nodes": "Nodes", + "no_nodes": "Right-click the effect to add a node.", + "add_node": "Add node", + "remove_node": "Remove node", + "off": "off", + "on": "on", + "play": "Play", + "pause": "Pause", + "restart": "Restart", + "toggle_floor": "Show floor", + "toggle_shape": "Show shape and handles", + "particles": "Particles", + "broken_file": "The file could not be read, so it is open read-only:", + + "node_group": "Group", + "node_emitter": "Emitter", + "node_plane": "Plane", + "node_cube": "Cube", + "node_sphere": "Sphere", + "node_cylinder": "Cylinder", + "node_model": "Model", + "node_trail": "Trail", + "node_beam": "Beam", + "node_post_effect": "Screen effect", + "node_sky": "Sky", + "node_camera_shake": "Camera shake", + "renders_particles": "Drawn on every particle of the emitter above it. Placement is added to each particle.", + "renders_once": "Drawn once, where it stands. Put it under an emitter to draw it on every particle.", + + "section_node": "Node", + "section_transform": "Placement", + "section_emission": "Emission", + "section_space": "Space and step", + "section_shape": "Shape", + "section_spawn": "At spawn", + "section_sub_emission": "Sub-emitter", + "section_particle": "Particle", + "section_mesh": "Mesh", + "section_trail": "Trail", + "section_beam": "Beam", + "section_shader": "Shader", + "section_uniforms": "Uniforms", + "section_samplers": "Textures", + "section_post": "Screen effect", + "section_sky": "Sky", + "section_shake": "Shake", + "section_motion": "Motion", + "section_material": "Material", + "section_depth": "Depth and culling", + "section_facing": "Facing", + "section_model": "Model", + "section_timeline": "Timeline", + "section_stats": "Contents", + + "name": "Name", + "enabled": "Enabled", + "position": "Position", + "rotation": "Rotation", + "scale": "Scale", + + "rate": "Rate", + "rate_hint": "How many particles appear per second.", + "burst": "Burst", + "burst_hint": "How many particles are released at once when each loop starts.", + "duration": "Duration", + "duration_hint": "Seconds the emitter keeps emitting. 0 means forever.", + "loop": "Loop", + "loop_delay": "Pause", + "loop_delay_hint": "Seconds of silence between loops.", + "max_particles": "Limit", + "max_particles_hint": "The emitter never has more particles alive than this.", + "max_step": "Simulation step", + "max_step_hint": "The longest simulation step in seconds. Smaller keeps fast particles and collisions accurate, at a cost.", + + "space_local": "Local", + "space_world": "World", + "inherit_rotation": "Follow rotation", + "inherit_scale": "Follow scale", + + "shape_point": "Point", + "shape_sphere": "Sphere", + "shape_box": "Box", + "shape_cone": "Cone", + "shape_disc": "Disc", + "shape_line": "Line", + "shape_model": "Model", + "shape_model_hint": "Particles are born on the surface of this model in its bind pose, and leave it along the surface normal.", + "radius": "Radius", + "extents": "Half size", + "extents_hint": "Half the width, height and depth of the box, in blocks.", + "line_end": "End", + "line_end_hint": "The point the line runs to from the node center.", + "angle": "Angle", + "angle_hint": "Half the opening angle of the cone, in degrees.", + "thickness": "Fill", + "thickness_hint": "0 puts particles on the surface only, 1 fills the whole volume.", + "direction": "Direction", + "direction_mode": "Launch direction", + "aim_shape": "From shape", + "aim_random": "Anywhere", + "aim_fixed": "Fixed", + + "lifetime": "Lifetime", + "lifetime_hint": "Seconds a particle lives.", + "speed": "Speed", + "speed_hint": "Starting speed in blocks per second.", + "spawn_offset": "Offset", + "spawn_offset_hint": "Added to the point the shape picked.", + "inherit_velocity": "Inertia", + "inherit_velocity_hint": "How much of the effect own velocity a particle keeps, 0 to 1.", + + "uniform_size": "Same on every axis", + "size": "Size", + "size_hint": "In blocks. A curve, or an expression on p.progress, changes it over the particle life.", + "spin": "Spin", + "spin_hint": "Angles in degrees. A camera-facing billboard only uses Z.", + "color": "Color", + "color_hint": "A gradient is read over the lifetime unless another source is picked.", + + "gravity": "Gravity", + "gravity_hint": "Blocks per second squared, pulling down. Negative pulls up.", + "drag": "Drag", + "drag_hint": "The share of speed lost per second, 0 to 1.", + + "texture": "Texture", + "blend": "Blending", + "blend_opaque": "Opaque", + "blend_blend": "Alpha", + "blend_additive": "Additive", + "blend_multiply": "Darken", + "blend_opaque_hint": "Covers whatever is behind. Alpha only cuts holes.", + "blend_blend_hint": "Ordinary transparency: smoke, dust, clouds. Sorted by depth.", + "blend_additive_hint": "Adds the surface color to the frame: useful for fire and sparks. It does not create a halo by itself; set Glow on the material for that.", + "blend_multiply_hint": "Multiplies what is behind: shadows, stains, soot.", + "lit_by_world": "Lit by the world", + "cull": "Hide the back face", + "depth_test": "Hidden by geometry", + "depth_write": "Writes depth", + "uv_from": "Texture region", + "uv_hint": "Which part of the texture is the sprite, from 0 to 1. The frame atlas is cut out of this region.", + "uv_to": "to", + + "facing_none": "As rotated", + "facing_camera": "Towards camera", + "facing_camera_axis": "Towards camera on an axis", + "facing_velocity": "Along motion", + "facing_axis": "Axis", + "facing_axis_hint": "In the space of the particles: the emitter for local particles, the world for world ones.", + + "tint": "Tint", + "tint_hint": "Multiplies the color. On a particle it multiplies the particle color; on a trail or beam a gradient runs from head to tail.", + "segments": "Segments", + "caps": "Closed ends", + "width": "Width", + "ribbon_width_hint": "In blocks, read along the ribbon: p.progress is 0 at the head and 1 at the tail.", + "trail_lifetime": "Length in time", + "trail_lifetime_hint": "Seconds a point of the trail stays behind.", + "trail_min_distance": "Step", + "trail_min_distance_hint": "How far the head moves, in blocks, before the trail takes a new point.", + "trail_max_points": "Points", + "ribbon_uv_stretch": "Stretch", + "ribbon_uv_tile": "Repeat", + "tile_length": "Texture length", + "tile_length_hint": "Blocks of ribbon one copy of the texture covers.", + "beam_target": "Target node", + "beam_end": "End", + "beam_end_hint": "Where the beam ends in the space of this node, when there is no target node.", + "beam_noise": "Jitter", + "beam_noise_hint": "How far, in blocks, noise pushes the middle of the beam aside. The ends stay put.", + "beam_frequency_hint": "Noise waves per block of beam.", + "beam_noise_speed": "Jitter speed", + "uv_scroll": "Texture scroll", + "uv_scroll_hint": "Texture lengths per second the texture slides from start to end.", + + "shader": "Core shader", + "surface_shader": "Material or shader", + "shader_plane_hint": "A material from the graph editor, namespace:materials/name.material, whose properties show up below; or a core shader, namespace:path of shaders/core/path.json. Attributes: Position, UV0, InstanceCenter, InstanceRight, InstanceUp, InstanceColor, InstanceUv, InstanceLight, InstanceBlend, InstanceMaterial (soft edge, glow). Samplers: Sampler0, Sampler2, SceneColor, SceneDepth. #moj_import gives hollowengine_soft_fade.", + "shader_mesh_hint": "A material from the graph editor, namespace:materials/name.material, whose properties show up below; or a core shader, namespace:path of shaders/core/path.json. Attributes: Position, UV0, Normal, InstanceX, InstanceY, InstanceZ, InstanceOrigin, InstanceColor, InstanceUv, InstanceLight. Samplers: Sampler0, Sampler2, SceneColor, SceneDepth. Uniforms Softness and Glow hold the material values; #moj_import gives hollowengine_soft_fade.", + "shader_ribbon_hint": "A material from the graph editor, namespace:materials/name.material, whose properties show up below; or a core shader, namespace:path of shaders/core/path.json. The vanilla particle format: Position, UV0, Color, UV2. Samplers: Sampler0, Sampler2, SceneColor, SceneDepth. Uniforms Softness and Glow hold the material values; #moj_import gives hollowengine_soft_fade.", + "shader_sky_hint": "namespace:path of shaders/core/path.json, drawn on the far plane: only the sky shows it, everything in front stays over it. Vertex shader hollowengine:vfx/sky/sky hands the fragment skyDirection, the view ray. The engine sets SkyCenter (direction to this node), NodeOffset (this node relative to the camera, in blocks) and EffectTime (seconds).", + "shader_post_hint": "namespace:path of shaders/core/path.json, drawn over the whole screen. Attributes: Position, UV0. Samplers: SceneColor, SceneDepth. ScreenSize is the size of the frame.", + "shader_missing": "No such shader: the json is looked for in assets/namespace/shaders/core/path.json.", + "uniforms_stale": "The file holds values for uniforms this shader does not declare:", + "uniforms_drop": "Forget them", + + "shake_strength": "Strength", + "shake_strength_hint": "Multiplies the swing. An expression such as 1 - n.camera_distance / 24 fades it with distance.", + "shake_frequency": "Frequency", + "shake_frequency_hint": "Shakes per second.", + "shake_amplitude": "Swing", + "shake_amplitude_hint": "The widest swing in degrees: pitch, yaw and roll.", + + "model": "Model", + "auto_play": "Start with the model", + "align_to_velocity": "Turn along motion", + "emissive": "Emissive", + + "timeline_duration": "Duration", + "timeline_duration_hint": "Length of the effect timeline in seconds.", + "timeline_loop": "Loop", + "expressions": "Expressions", + + "kind_const": "Number", + "kind_range": "Range", + "kind_curve": "Curve", + "kind_expr": "Expression", + "expression_placeholder": "p.progress * 2", + "expression_hint": "p. is the particle, e. the emitter, n. the node, d. server data, math. the functions.", + "curve_open": "Click to open the curve editor", + "curve_title": "Curve", + "close": "Close", + "driven_hint": "Timeline keys set this value. Remove the keys to edit it here.", + "driven_show": "Show the track on the timeline", + "curve_fade_out": "Fade out", + "curve_fade_in": "Fade in", + "curve_flat": "Flat", + "curve_scale": "Multiplier", + "key_remove": "Remove", + + "source_lifetime": "Over lifetime", + "source_particle_age": "Age, s", + "source_emitter_age": "Emitter age, s", + "source_effect_time": "Effect time, s", + "source_speed": "Speed", + "source_random": "Random per particle", + "source_parent_random": "Parent random", + "sub_enabled": "Spawn from parent particles", + "sub_enabled_hint": "Spawn particles when a particle of the parent emitter triggers the selected event, instead of using this emitter's own rate. The spawn shape is centered on the event. For example, rocket deaths can trigger firework bursts. The parent particle's random number is available as p.parent_random and the Parent random source.", + "sub_event_spawn": "Spawn", + "sub_event_death": "Death", + "sub_event_collision": "Collision", + "sub_event_alive": "While alive", + "sub_count": "Count", + "sub_count_hint": "Particles per event. In an expression, p. refers to the parent particle that triggered it.", + "sub_count_alive_hint": "Particles per second for each living parent particle. In an expression, p. refers to that particle.", + "sub_inherit_velocity_hint": "Fraction of the parent particle's velocity passed to new particles, from 0 to 1.", + "glow": "Glow", + "glow_hint": "Strength of the halo around the surface. Its color is blurred and added over the frame using scene depth. 0 disables glow; values above 1 are not clamped in the glow pass. A custom shader must declare GlowPass and use the Glow value.", + "softness": "Soft intersection", + "softness_hint": "Distance in blocks over which the surface fades as it approaches scene geometry. 0 disables the fade. A custom shader must apply this value itself.", + + "color_solid": "Solid", + "color_gradient": "Gradient", + "color_channels": "Per channel", + "stop_at": "at", + "stop_add": "Add stop", + + "module_velocity_over_lifetime": "Extra velocity", + "module_force": "Force", + "module_noise": "Noise", + "module_collision": "Collisions", + "module_uv_animation": "Frame atlas", + "module_add": "Add module", + "module_enabled": "Enabled", + "module_remove": "Remove", + "module_unknown": "This module comes from an addon that is not loaded. It is kept as it is.", + + "velocity": "Velocity", + "velocity_hint": "Added to the particle motion without changing its own velocity.", + "force_directional": "Wind", + "force_point": "From a point", + "force_vortex": "Vortex", + "force_drag": "Braking", + "strength": "Strength", + "force_strength_hint": "Blocks per second squared; for braking, the share of speed per second. Negative pulls towards the center.", + "force_center": "Center", + "force_center_hint": "A point in the emitter frame.", + "force_radius_hint": "In blocks. 0 reaches everywhere.", + "falloff": "Fades to the edge", + "frequency": "Detail", + "frequency_hint": "Noise cells per block: higher is finer and more chaotic.", + "scroll_speed": "Drift", + "scroll_speed_hint": "How fast the noise field itself moves.", + "octaves": "Layers", + "octaves_hint": "Extra layers of finer detail. Each one doubles the cost.", + "hit_die": "Die", + "hit_bounce": "Bounce", + "hit_slide": "Slide", + "bounce": "Bounciness", + "bounce_hint": "The share of speed left after a bounce, 0 to 1.", + "friction": "Friction", + "friction_hint": "The share of speed along the surface kept after touching it.", + "hit_radius": "Radius", + "hit_radius_hint": "The particle is treated as a sphere of this radius, in blocks.", + "lifetime_loss": "Life lost", + "lifetime_loss_hint": "Seconds of life a particle loses on every hit.", + "uv_columns": "Columns", + "uv_rows": "Rows", + "uv_frames": "Frames", + "uv_frames_hint": "How many grid cells hold a frame, row by row. 0 means all of them.", + "uv_over_lifetime": "Over life", + "uv_fps": "At FPS", + "uv_random_frame": "Random frame", + "fps": "Frames per second", + "cycles": "Passes", + "cycles_hint": "How many times the atlas plays over the particle life.", + + "property_enabled": "Enabled", + "property_position": "Position", + "property_rotation": "Rotation", + "property_scale": "Scale", + "add_child": "Add inside", + "duplicate": "Duplicate", + "rename": "Rename", + "enable": "Switch on", + "disable": "Switch off", + "move_up": "Move up", + "move_down": "Move down", + "move_out": "Move out of the parent", + "shader_placeholder": "built-in", + "beam_target_hint": "The node the beam ends at. Leave empty to end it at the point below.", + "beam_target_none": "a point", + "uv_region": "UV region" +} diff --git a/runtime/src/main/lang/ru_ru/hollowengine/commands.json b/runtime/src/main/lang/ru_ru/hollowengine/commands.json index a5b1433e8..f0c7438a7 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/commands.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/commands.json @@ -1,7 +1,10 @@ { "copy": "§6[§bHollow Engine§6] §7Значение: %s скопировано.", "model_animations": "§6[§bHollow Engine§6] Анимации модели %s:", - "model_textures": "§6[§bHollow Engine§6] Текстуры модели %s:", + "model_materials": "§6[§bHollow Engine§6] Материалы модели %s:", + "model_unreadable": "Не удалось прочитать модель %s: проверьте путь и файл", + "entity_without_model": "У %s нет компонента модели, показывается ванильная модель", + "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", 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 040043572..7f2538ca7 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 @@ -1,18 +1,13 @@ { - "layers": "Слои", - "layers_empty": "Пусто. Добавьте слой ниже.", - "add_layer": "+ Добавить", + "add_layer": "Добавить слой", "kind_controller": "Контроллер", "kind_clip": "Клип", "kind_procedural": "Процедурный", - "show_layers": "Показать слои", - "hide_layers": "Скрыть слои", "reset_view": "Сбросить вид", "parameters": "Параметры", "no_graph": "У этого слоя нет графа, его параметры в инспекторе.", "no_layers": "Слоёв нет", "nothing_selected": "Ничего не выбрано", - "pick_controller": "Выберите слой-контроллер", "layer_removed": "Слой удалён", "state_removed": "Состояние удалено", "transition_removed": "Переход удалён", @@ -61,5 +56,7 @@ "exit_time": "Время завершения", "condition": "Условие", "any_state": "любое состояние", - "always": "всегда" + "always": "всегда", + "delete_layer": "Удалить слой", + "no_layers_hint": "Слои находятся в окне «Сцена»: добавьте слой через правый клик там." } 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 a2de23141..d655c36ce 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/console.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/console.json @@ -11,5 +11,7 @@ "auto_scroll": "Автопрокрутка", "empty": "Нет сообщений, соответствующих текущему фильтру", "mode.command": "Команда", - "mode.kotlin": "Kotlin" + "mode.kotlin": "Kotlin", + "tab.logs": "Логи", + "tab.problems": "Проблемы" } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/docking.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/docking.json index 96a5e956c..72f047dcf 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/docking.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/docking.json @@ -1,6 +1,15 @@ { "pin_left": "Закрепить слева", "pin_right": "Закрепить справа", + "pin_left_split": "Закрепить слева, под панелью", + "pin_right_split": "Закрепить справа, под панелью", + "pin_left_bottom": "Закрепить снизу слева", + "pin_right_bottom": "Закрепить снизу справа", + "move_to_top": "Перенести в боковую панель", + "move_to_split": "Перенести под боковую панель", + "move_to_bottom": "Перенести в нижнюю область", + "swap_panels": "Поменять местами с соседней панелью", + "park_here": "Закрепить здесь", "undock": "Открепить", "close": "Закрыть", "hide_stripes": "Скрыть боковые полоски", diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json index a738a4a18..c94882c12 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/cutscene.json @@ -58,24 +58,26 @@ "handles.auto_hint": "Считаются по соседним ключам. Потяни направляющую в режиме кривых, чтобы задать вручную; Shift - сохранить длину противоположной, Ctrl - двигать только одну.", "handles.incoming": "вх", "handles.outgoing": "исх", - "layer.settings": "Слой", - "property.settings": "Свойство", - "layer.weight": "Вес", - "layer.visible": "Видим", - "layer.locked": "Заблокирован", - "layer.delete": "Удалить слой", - "layer.blend_mode": "Смешивание", - "blend.override": "Перезапись", - "blend.add": "Сложение", - "blend.subtract": "Вычитание", - "blend.multiply": "Умножение", "rotation.mode": "Представление поворота", "rotation.euler": "Эйлер", "rotation.quaternion": "Кватернион", - "rotation.mode_hint": "Переключение пересчитывает кривые на новые каналы, направляющие сбрасываются.", - "limits.title": "Ограничения", - "limits.hint": "Задаются типом свойства; редактор держит ключи внутри них.", "weather.clear": "Ясно", "weather.rain": "Дождь", - "weather.thunder": "Гроза" + "weather.thunder": "Гроза", + "track.select_keys": "Выделить ключи", + "track.show": "Показать", + "track.hide": "Скрыть", + "track.lock": "Заблокировать", + "track.unlock": "Разблокировать", + "track.remove": "Убрать из списка", + "action.to_start": "В начало (Home)", + "action.to_end": "В конец", + "action.play": "Пуск или пауза (Пробел)", + "action.prev_key": "Предыдущий ключ (Вниз)", + "action.next_key": "Следующий ключ (Вверх)", + "action.zoom_in": "Приблизить (Ctrl+колесо)", + "action.zoom_out": "Отдалить (Ctrl+колесо)", + "action.more": "Ещё", + "view.frame_time": "Вписать рабочую область (F)", + "preview.time_hint": "Где стоит курсор; введите время, чтобы перейти" } 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 100f12828..989938038 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 @@ -13,5 +13,8 @@ "status.definition_not_found": "Определение не найдено", "status.resource_not_found": "Не удалось найти «%s»", "status.unsupported_action": "Это действие не поддерживается", - "status.unsupported_definition": "Не удалось открыть определение в %s" + "status.unsupported_definition": "Не удалось открыть определение в %s", + "completion.navigate": "— выбор,", + "completion.or": "или", + "completion.insert": "— вставка" } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/problems.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/problems.json new file mode 100644 index 000000000..cc15d3408 --- /dev/null +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/problems.json @@ -0,0 +1,7 @@ +{ + "show": "Показать в «Проблемах»", + "no_file": "Откройте файл, чтобы увидеть его проблемы", + "none": "Проблем не найдено", + "copy": "Скопировать сообщение", + "copy_all": "Скопировать все" +} diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/status.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/status.json new file mode 100644 index 000000000..ad8b6f1c7 --- /dev/null +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/status.json @@ -0,0 +1,5 @@ +{ + "resources": "Ресурсы игры", + "read_only": "Только чтение", + "caret": "Строка %s, столбец %s" +} diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/windows.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/windows.json index c28b2cf4f..9fd067ccd 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/windows.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/ide/windows.json @@ -1,6 +1,9 @@ { - "cutscene_timeline": "Таймлайн катсцены", + "timeline": "Таймлайн", + "scene": "Сцена", + "scene_empty": "Откройте то, у чего есть иерархия, и она появится здесь.", "inspector": "Инспектор", "game_viewport": "Окно игры", - "game_viewport.detached": "Игра отображается только в прикреплённом режиме" + "game_viewport.detached": "Игра отображается только в прикреплённом режиме", + "scene_add_hint": "Нажмите ПКМ, чтобы добавить." } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/inspector.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/inspector.json index 5cf4d0795..d996bb321 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/inspector.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/inspector.json @@ -1,13 +1,18 @@ { "add": "Добавить", + "asset_missing": "В загруженных паках нет такого файла: %s", + "asset_use": "Заменить на %s", "bone.clear": "Оставить пустым", "bone.no_matches": "Кости с таким именем нет.", "bone.none": "не выбрана", "bone.search": "Найти кость", + "close": "Закрыть", "empty_list": "Пусто", "not_set": "Не задано", + "nothing_found": "Ничего не найдено", "nothing_selected": "Выберите что-нибудь, чтобы увидеть параметры", "pick": "Выбрать из загруженных", "remove": "Удалить", + "search": "Поиск", "unsupported": "%s нельзя отредактировать здесь" } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/rig_editor.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/rig_editor.json index d01b86ffd..88762bd73 100644 --- a/runtime/src/main/lang/ru_ru/hollowengine/gui/rig_editor.json +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/rig_editor.json @@ -16,5 +16,6 @@ "toggle_colliders": "Показать коллайдеры", "start_physics": "Уронить риг и посмотреть, что будет", "stop_physics": "Остановить симуляцию", - "kind_item": "Предмет" + "kind_item": "Предмет", + "kind_vfx": "Эффект" } diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json new file mode 100644 index 000000000..22084b017 --- /dev/null +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/shadergraph.json @@ -0,0 +1,127 @@ +{ + "graph": "Материал", + "category.input": "Входы", + "category.math": "Математика", + "category.vector": "Векторы", + "category.normal": "Нормали", + "category.uv": "UV", + "category.texture": "Текстуры", + "category.procedural": "Процедурные", + "preview": "Превью", + "show_preview": "Показывать превью", + "preview_mesh": "Показывать на", + "preview_rotate": "Вращать меш", + "preview_texture": "Основная текстура в превью", + "mesh.quad": "Квад", + "mesh.sphere": "Сфера", + "mesh.cube": "Куб", + "pick_property": "Выбрать…", + "option.property": "Свойство", + "option.mask": "Маска", + "option.flip_x": "Отразить по горизонтали", + "option.flip_y": "Отразить по вертикали", + "option.expression": "Выражение", + "option.type": "Тип", + "option.type.auto": "По выражению", + "option.shape": "Фигура", + "option.shape.ellipse": "Эллипс", + "option.shape.rectangle": "Прямоугольник", + "option.shape.polygon": "Многоугольник", + "option.shape.ring": "Кольцо", + "expression.empty": "Выражение пустое", + "expression.unexpected_character": "Здесь недопустим символ", + "expression.unexpected_token": "Не ожидалось", + "expression.expected_value": "Ожидалось значение", + "expression.unclosed": "Не закрыта скобка", + "expression.unknown_function": "Нет такой функции", + "expression.reserved_name": "Это имя занято GLSL или движком", + "duplicate": "Дублировать", + "delete_node": "Удалить узел", + "delete_link": "Удалить связь", + "inputs": "Входы", + "options": "Параметры", + "property": "Свойство", + "no_properties": "У графа пока нет свойств: добавьте их, сняв выделение.", + "properties": "Свойства", + "add_property": "Добавить свойство", + "remove_property": "Удалить свойство", + "texture": "Текстура", + "problems": "Проблемы", + "no_problems": "Граф компилируется.", + "problem.unknown_node": "Неизвестный узел", + "problem.unknown_pin": "Неизвестный пин", + "problem.no_output_node": "У графа нет выходного узла", + "problem.second_output_node": "Используется только первый выходной узел", + "problem.cycle": "Узел стоит в цикле и пропущен", + "problem.type_mismatch": "Эта связь не может передать такой тип", + "problem.unresolved_type": "Тип выхода неизвестен: выберите свойство", + "problem.fragment_only_in_vertex": "Работает только попиксельно, а нужен смещению вершин", + "problem.unavailable_input": "Здесь недоступно", + "problem.invalid_expression": "Ошибка в выражении", + "problem.glsl_error": "Шейдер не скомпилировался", + "node.input.uv": "UV", + "node.input.texture_uv": "UV текстуры", + "node.input.position": "Позиция", + "node.input.object_position": "Позиция в объекте", + "node.input.normal": "Нормаль", + "node.input.view_direction": "Направление взгляда", + "node.input.screen_uv": "UV экрана", + "node.input.vertex_color": "Цвет партикла", + "node.input.time": "Время", + "node.input.scene_depth": "Глубина сцены", + "node.input.scene_color": "Цвет сцены", + "node.input.main_texture": "Основная текстура", + "node.input.property": "Свойство", + "node.input.float": "Число", + "node.input.vector2": "Вектор 2", + "node.input.vector3": "Вектор 3", + "node.input.vector4": "Вектор 4", + "node.input.color": "Цвет", + "node.math.add": "Сложить", + "node.math.subtract": "Вычесть", + "node.math.multiply": "Умножить", + "node.math.divide": "Разделить", + "node.math.power": "Степень", + "node.math.minimum": "Минимум", + "node.math.maximum": "Максимум", + "node.math.one_minus": "Один минус", + "node.math.negate": "Отрицание", + "node.math.absolute": "Модуль", + "node.math.fraction": "Дробная часть", + "node.math.floor": "Округление вниз", + "node.math.sine": "Синус", + "node.math.cosine": "Косинус", + "node.math.square_root": "Квадратный корень", + "node.math.saturate": "Ограничить 0–1", + "node.math.clamp": "Ограничить", + "node.math.lerp": "Интерполяция", + "node.math.step": "Порог", + "node.math.smoothstep": "Плавный порог", + "node.math.remap": "Перевод диапазона", + "node.math.modulo": "Остаток", + "node.math.expression": "Выражение", + "node.vector.length": "Длина", + "node.vector.normalize": "Нормализовать", + "node.vector.dot": "Скалярное произведение", + "node.vector.distance": "Расстояние", + "node.vector.cross": "Векторное произведение", + "node.vector.split": "Разбить", + "node.vector.combine": "Собрать", + "node.vector.swizzle": "Перестановка", + "node.uv.tiling_offset": "Повтор и сдвиг", + "node.uv.rotate": "Поворот", + "node.uv.polar": "Полярные координаты", + "node.uv.scroll": "Прокрутка", + "node.texture.sample": "Выборка текстуры", + "node.procedural.value_noise": "Шум значений", + "node.procedural.gradient_noise": "Градиентный шум", + "node.procedural.fbm": "Фрактальный шум", + "node.procedural.voronoi": "Вороной", + "node.procedural.shape": "Фигура", + "node.normal.offset": "Смещение по нормали", + "node.normal.bump": "Рельеф", + "node.normal.surface": "Нормаль поверхности", + "node.normal.lambert": "Освещение по Ламберту", + "node.procedural.fresnel": "Френель", + "node.output.surface": "Выход поверхности" +} diff --git a/runtime/src/main/lang/ru_ru/hollowengine/gui/vfx.json b/runtime/src/main/lang/ru_ru/hollowengine/gui/vfx.json new file mode 100644 index 000000000..27ac5b191 --- /dev/null +++ b/runtime/src/main/lang/ru_ru/hollowengine/gui/vfx.json @@ -0,0 +1,316 @@ +{ + "effect": "Эффект", + "nodes": "Узлы", + "no_nodes": "Нажмите ПКМ по эффекту, чтобы добавить узел.", + "add_node": "Добавить узел", + "remove_node": "Удалить узел", + "off": "выкл", + "on": "вкл", + "play": "Воспроизвести", + "pause": "Пауза", + "restart": "Заново", + "toggle_floor": "Показывать пол", + "toggle_shape": "Показывать форму и ручки", + "particles": "Партиклов", + "broken_file": "Файл не удалось прочитать, поэтому он открыт только для чтения:", + + "node_group": "Группа", + "node_emitter": "Эмиттер", + "node_plane": "Плоскость", + "node_cube": "Куб", + "node_sphere": "Сфера", + "node_cylinder": "Цилиндр", + "node_model": "Модель", + "node_trail": "Шлейф", + "node_beam": "Луч", + "node_post_effect": "Эффект экрана", + "node_sky": "Небо", + "node_camera_shake": "Тряска камеры", + "renders_particles": "Рисуется на каждом партикле эмиттера над ним. Размещение прибавляется к каждому партиклу.", + "renders_once": "Рисуется один раз, там где стоит. Положите его под эмиттер, чтобы рисовать на каждом партикле.", + + "section_node": "Узел", + "section_transform": "Расположение", + "section_emission": "Испускание", + "section_space": "Пространство и шаг", + "section_shape": "Форма", + "section_spawn": "При рождении", + "section_sub_emission": "Саб-эмиттер", + "section_particle": "Партикл", + "section_mesh": "Меш", + "section_trail": "Шлейф", + "section_beam": "Луч", + "section_shader": "Шейдер", + "section_uniforms": "Uniform'ы", + "section_samplers": "Текстуры", + "section_post": "Эффект экрана", + "section_sky": "Небо", + "section_shake": "Тряска", + "section_motion": "Движение", + "section_material": "Материал", + "section_depth": "Глубина и отсечение", + "section_facing": "Ориентация", + "section_model": "Модель", + "section_timeline": "Таймлайн", + "section_stats": "Состав", + + "name": "Название", + "enabled": "Включён", + "position": "Позиция", + "rotation": "Поворот", + "scale": "Масштаб", + + "rate": "Частота", + "rate_hint": "Сколько партиклов появляется в секунду.", + "burst": "Залп", + "burst_hint": "Сколько партиклов выпускается разом в начале каждого цикла.", + "duration": "Длительность", + "duration_hint": "Сколько секунд эмиттер испускает партиклы. Без ограничений: 0.", + "loop": "Повторять", + "loop_delay": "Пауза", + "loop_delay_hint": "Секунд тишины между повторами.", + "max_particles": "Предел", + "max_particles_hint": "Больше этого числа партиклов у эмиттера одновременно не бывает.", + "max_step": "Шаг симуляции", + "max_step_hint": "Самый длинный шаг симуляции в секундах. Чем меньше, тем точнее партиклы и коллизии, но дороже по производительности.", + + "space_local": "Локальное", + "space_world": "Мировое", + "inherit_rotation": "Следовать за поворотом", + "inherit_scale": "Следовать за масштабом", + + "shape_point": "Точка", + "shape_sphere": "Сфера", + "shape_box": "Куб", + "shape_cone": "Конус", + "shape_disc": "Диск", + "shape_line": "Линия", + "shape_model": "Модель", + "shape_model_hint": "Партиклы рождаются на поверхности этой модели в бинд-позе и вылетают по нормали поверхности.", + "radius": "Радиус", + "extents": "Полуразмеры", + "extents_hint": "Половина ширины, высоты и глубины куба, в блоках.", + "line_end": "Конец", + "line_end_hint": "Точка, до которой идёт линия от центра узла.", + "angle": "Угол", + "angle_hint": "Половина угла раскрытия конуса, в градусах.", + "thickness": "Заполнение", + "thickness_hint": "По поверхности: 0.\nПо всему объёму: 1.", + "direction": "Направление", + "direction_mode": "Куда летят", + "aim_shape": "Из формы", + "aim_random": "Куда угодно", + "aim_fixed": "Заданное", + + "lifetime": "Время жизни", + "lifetime_hint": "Сколько секунд живёт партикл.", + "speed": "Скорость", + "speed_hint": "Начальная скорость, блоков в секунду.", + "spawn_offset": "Смещение", + "spawn_offset_hint": "Добавляется к точке, которую выбрала форма.", + "inherit_velocity": "Инерция", + "inherit_velocity_hint": "Какую долю скорости самого эффекта получает партикл, от 0 до 1.", + + "uniform_size": "Одинаково по осям", + "size": "Размер", + "size_hint": "В блоках. Кривая или выражение на p.progress меняют размер за жизнь партикла.", + "spin": "Вращение", + "spin_hint": "Углы в градусах. Для билборда к камере работает только Z.", + "color": "Цвет", + "color_hint": "Градиент читается по времени жизни, если не выбран другой источник.", + + "gravity": "Гравитация", + "gravity_hint": "Блоков в секунду за секунду, тянет вниз. Отрицательная гравитация поднимает партиклы вверх.", + "drag": "Сопротивление", + "drag_hint": "Какую долю скорости партикл теряет за секунду, от 0 до 1.", + + "texture": "Текстура", + "blend": "Наложение", + "blend_opaque": "Без смешивания", + "blend_blend": "Прозрачность", + "blend_additive": "Аддитивное смешивание", + "blend_multiply": "Затемнение", + "blend_opaque_hint": "Закрывает всё позади. Альфа только вырезает дырки.", + "blend_blend_hint": "Обычная прозрачность: дым, пыль, облака. Сортируется по глубине.", + "blend_additive_hint": "Добавляет цвет поверхности к кадру: подходит для огня и искр. Само по себе не создаёт ореол; для него задайте Свечение в материале.", + "blend_multiply_hint": "Умножает то, что позади: тени, пятна, копоть.", + "lit_by_world": "Освещается миром", + "cull": "Скрывать обратную сторону", + "depth_test": "Прячется за геометрией", + "depth_write": "Пишет глубину", + "uv_from": "Область текстуры", + "uv_hint": "Какая часть текстуры это спрайт, в промежутке от 0 до 1. Атлас кадров режется из этой области.", + "uv_to": "до", + + "facing_none": "Как повёрнут", + "facing_camera": "К камере", + "facing_camera_axis": "К камере вокруг оси", + "facing_velocity": "По движению", + "facing_axis": "Ось", + "facing_axis_hint": "В пространстве партиклов: эмиттера для локальных, мира для мировых.", + + "tint": "Оттенок", + "tint_hint": "Умножает цвет. На партикле умножает цвет партикла; у шлейфа и луча градиент идёт от головы к хвосту.", + "segments": "Сегменты", + "caps": "Закрытые торцы", + "width": "Ширина", + "ribbon_width_hint": "В блоках, читается вдоль ленты: p.progress, 0 у головы и 1 у хвоста.", + "trail_lifetime": "Длина во времени", + "trail_lifetime_hint": "Сколько секунд точка шлейфа остаётся позади.", + "trail_min_distance": "Шаг", + "trail_min_distance_hint": "Сколько блоков должна пройти голова, прежде чем шлейф возьмёт новую точку.", + "trail_max_points": "Точек", + "ribbon_uv_stretch": "Растянуть", + "ribbon_uv_tile": "Повторять", + "tile_length": "Длина текстуры", + "tile_length_hint": "Сколько блоков ленты покрывает одна копия текстуры.", + "beam_target": "Целевой узел", + "beam_end": "Конец", + "beam_end_hint": "Где кончается луч в пространстве этого узла, если целевого узла нет.", + "beam_noise": "Дрожание", + "beam_noise_hint": "На сколько блоков шум уводит середину луча в сторону. Концы остаются на месте.", + "beam_frequency_hint": "Волн шума на блок луча.", + "beam_noise_speed": "Скорость дрожания", + "uv_scroll": "Прокрутка текстуры", + "uv_scroll_hint": "Сколько длин текстуры в секунду она проезжает от начала к концу.", + + "shader": "Core-шейдер", + "surface_shader": "Материал или шейдер", + "shader_plane_hint": "Материал из редактора графов, namespace:materials/имя.material, его свойства появятся ниже; или core-шейдер, namespace:path для shaders/core/path.json. Атрибуты: Position, UV0, InstanceCenter, InstanceRight, InstanceUp, InstanceColor, InstanceUv, InstanceLight, InstanceBlend, InstanceMaterial (мягкий край, свечение). Сэмплеры: Sampler0, Sampler2, SceneColor, SceneDepth. #moj_import даёт hollowengine_soft_fade.", + "shader_mesh_hint": "Материал из редактора графов, namespace:materials/имя.material, его свойства появятся ниже; или core-шейдер, namespace:path для shaders/core/path.json. Атрибуты: Position, UV0, Normal, InstanceX, InstanceY, InstanceZ, InstanceOrigin, InstanceColor, InstanceUv, InstanceLight. Сэмплеры: Sampler0, Sampler2, SceneColor, SceneDepth. Uniform'ы Softness и Glow хранят значения материала; #moj_import даёт hollowengine_soft_fade.", + "shader_ribbon_hint": "Материал из редактора графов, namespace:materials/имя.material, его свойства появятся ниже; или core-шейдер, namespace:path для shaders/core/path.json. Ванильный формат партиклов: Position, UV0, Color, UV2. Сэмплеры: Sampler0, Sampler2, SceneColor, SceneDepth. Uniform'ы Softness и Glow хранят значения материала; #moj_import даёт hollowengine_soft_fade.", + "shader_sky_hint": "namespace:path для shaders/core/path.json, рисуется на дальней плоскости: виден только на небе, всё, что ближе, остаётся поверх. Вершинный шейдер hollowengine:vfx/sky/sky передаёт фрагменту skyDirection - луч взгляда. Движок задаёт SkyCenter (направление на этот узел), NodeOffset (узел относительно камеры, в блоках) и EffectTime (секунды).", + "shader_post_hint": "namespace:path для shaders/core/path.json, рисуется на весь экран. Атрибуты: Position, UV0. Сэмплеры: SceneColor, SceneDepth. ScreenSize — размер кадра.", + "shader_missing": "Такого шейдера нет: json ищется в assets/namespace/shaders/core/path.json.", + "uniforms_stale": "В файле есть значения uniform'ов, которых этот шейдер не объявляет:", + "uniforms_drop": "Забыть их", + + "shake_strength": "Сила", + "shake_strength_hint": "Умножает размах. Выражение вроде 1 - n.camera_distance / 24 ослабляет тряску с расстоянием.", + "shake_frequency": "Частота", + "shake_frequency_hint": "Колебаний в секунду.", + "shake_amplitude": "Размах", + "shake_amplitude_hint": "Наибольший размах в градусах: тангаж, рыскание и крен.", + + "model": "Модель", + "auto_play": "Запускать вместе с моделью", + "align_to_velocity": "Поворачивать по движению", + "emissive": "Светящийся", + + "timeline_duration": "Длительность", + "timeline_duration_hint": "Длина таймлайна эффекта в секундах.", + "timeline_loop": "Повторять", + "expressions": "Выражений", + + "kind_const": "Число", + "kind_range": "Диапазон", + "kind_curve": "Кривая", + "kind_expr": "Выражение", + "expression_placeholder": "p.progress * 2", + "expression_hint": "p. - партикл, e. - эмиттер, n. - узел, d. - данные с сервера, math. - функции.", + "curve_open": "Нажмите, чтобы открыть редактор кривой", + "curve_title": "Кривая", + "close": "Закрыть", + "driven_hint": "Это значение задают ключи на таймлайне. Чтобы менять его здесь, удалите ключи.", + "driven_show": "Показать дорожку на таймлайне", + "curve_fade_out": "Затухание", + "curve_fade_in": "Нарастание", + "curve_flat": "Ровно", + "curve_scale": "Множитель", + "key_remove": "Удалить", + + "source_lifetime": "По времени жизни", + "source_particle_age": "Возраст, с", + "source_emitter_age": "Возраст эмиттера, с", + "source_effect_time": "Время эффекта, с", + "source_speed": "Скорость", + "source_random": "Случайно для партикла", + "source_parent_random": "Случайное число родителя", + "sub_enabled": "Рождать от частиц родителя", + "sub_enabled_hint": "Эмиттер создаёт частицы при выбранном событии у частиц родительского эмиттера, а не по собственной частоте. Его форма строится вокруг места события. Например, вспышки фейерверка можно создавать при гибели ракет. Случайное число родительской частицы доступно через p.parent_random и источник «Случайное число родителя».", + "sub_event_spawn": "Рождение", + "sub_event_death": "Смерть", + "sub_event_collision": "Столкновение", + "sub_event_alive": "Пока жив", + "sub_count": "Количество", + "sub_count_hint": "Частиц на одно событие. В выражении p. обозначает родительскую частицу, вызвавшую событие.", + "sub_count_alive_hint": "Частиц в секунду для каждой живой родительской частицы. В выражении p. обозначает эту частицу.", + "sub_inherit_velocity_hint": "Доля скорости родительской частицы, передаваемая новым частицам: от 0 до 1.", + "glow": "Свечение", + "glow_hint": "Сила ореола вокруг поверхности. Её цвет размывается и добавляется поверх кадра с учётом глубины сцены. 0 отключает свечение; значения больше 1 усиливают его без ограничения единицей в проходе свечения. Пользовательский шейдер должен объявлять GlowPass и использовать значение Glow.", + "softness": "Мягкое пересечение", + "softness_hint": "Расстояние в блоках, на котором поверхность плавно исчезает при приближении к геометрии сцены. 0 отключает смягчение. Пользовательский шейдер должен сам применять это значение.", + + "color_solid": "Один цвет", + "color_gradient": "Градиент", + "color_channels": "По каналам", + "stop_at": "в", + "stop_add": "Добавить точку", + + "module_velocity_over_lifetime": "Добавочная скорость", + "module_force": "Сила", + "module_noise": "Шум", + "module_collision": "Коллизии", + "module_uv_animation": "Атлас кадров", + "module_add": "Добавить модуль", + "module_enabled": "Включён", + "module_remove": "Удалить", + "module_unknown": "Этот модуль из аддона, который не загружен. Он сохраняется как есть.", + + "velocity": "Скорость", + "velocity_hint": "Прибавляется к движению партикла, но не меняет его собственную скорость.", + "force_directional": "Ветер", + "force_point": "От точки", + "force_vortex": "Вихрь", + "force_drag": "Торможение", + "strength": "Сила", + "force_strength_hint": "Блоков/сек. за секунду; для торможения - доля скорости в секунду. Отрицательная сила тянет к центру.", + "force_center": "Центр", + "force_center_hint": "Точка в координатах эмиттера.", + "force_radius_hint": "В блоках. 0 - действует везде.", + "falloff": "Слабеет к краю", + "frequency": "Детализация", + "frequency_hint": "Ячеек шума на блок: чем больше, тем мельче и хаотичнее.", + "scroll_speed": "Дрейф", + "scroll_speed_hint": "Как быстро движется само поле шума.", + "octaves": "Слои", + "octaves_hint": "Дополнительные слои мелких деталей. Каждый удваивает стоимость.", + "hit_die": "Умереть", + "hit_bounce": "Отскочить", + "hit_slide": "Скользить", + "bounce": "Упругость", + "bounce_hint": "Какая доля скорости остаётся после отскока, от 0 до 1.", + "friction": "Трение", + "friction_hint": "Какая доля скорости вдоль поверхности остаётся после касания.", + "hit_radius": "Радиус", + "hit_radius_hint": "Партикл считается шаром такого радиуса, в блоках.", + "lifetime_loss": "Потеря жизни", + "lifetime_loss_hint": "Сколько секунд жизни партикл теряет за каждый удар.", + "uv_columns": "Столбцов", + "uv_rows": "Строк", + "uv_frames": "Кадров", + "uv_frames_hint": "Сколько ячеек сетки заняты кадрами, по строкам. 0 - все.", + "uv_over_lifetime": "За жизнь", + "uv_fps": "По FPS", + "uv_random_frame": "Случайный кадр", + "fps": "Кадров в секунду", + "cycles": "Проходов", + "cycles_hint": "Сколько раз атлас проигрывается за жизнь партикла.", + + "property_enabled": "Включён", + "property_position": "Позиция", + "property_rotation": "Поворот", + "property_scale": "Масштаб", + "add_child": "Добавить внутрь", + "duplicate": "Дублировать", + "rename": "Переименовать", + "enable": "Включить", + "disable": "Выключить", + "move_up": "Выше", + "move_down": "Ниже", + "move_out": "Вынести из родителя", + "shader_placeholder": "встроенный", + "beam_target_hint": "Узел, в котором заканчивается луч. Пусто - луч заканчивается в точке ниже.", + "beam_target_none": "точка", + "uv_region": "Область UV" +} diff --git a/runtime/src/main/resources/assets/hollowengine/fonts/jetbrains_mono_nl.ttf b/runtime/src/main/resources/assets/hollowengine/fonts/jetbrains_mono_nl.ttf new file mode 100644 index 000000000..70d2ec9e2 Binary files /dev/null and b/runtime/src/main/resources/assets/hollowengine/fonts/jetbrains_mono_nl.ttf differ diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.fsh new file mode 100644 index 000000000..1ca33798e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.fsh @@ -0,0 +1,35 @@ +#version 150 + +uniform sampler2D Sampler0; +uniform sampler2D SceneDepth; + +uniform mat4 ProjMat; +uniform vec2 ScreenSize; +uniform float GameTime; +uniform vec4 FieldColor; +uniform float FresnelPower; +uniform float IntersectionWidth; + +in vec3 viewPosition; +in vec3 viewNormal; +in vec4 vertexColor; +in vec2 texCoord0; + +out vec4 fragColor; + +float eyeDistance(float depth) { + return ProjMat[3][2] / (depth * 2.0 - 1.0 + ProjMat[2][2]); +} + +void main() { + float facing = abs(dot(normalize(viewNormal), normalize(-viewPosition))); + float rim = pow(1.0 - facing, FresnelPower); + + float scene = eyeDistance(texture(SceneDepth, gl_FragCoord.xy / ScreenSize).r); + float gap = scene - eyeDistance(gl_FragCoord.z); + float contact = 1.0 - clamp(gap / max(IntersectionWidth, 0.001), 0.0, 1.0); + + float ripple = 0.85 + 0.15 * sin(texCoord0.y * 40.0 - GameTime * 4000.0); + vec4 color = FieldColor * vertexColor * texture(Sampler0, texCoord0); + fragColor = vec4(color.rgb, color.a * clamp(max(rim * ripple, contact), 0.0, 1.0)); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.json new file mode 100644 index 000000000..ca6799f2a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.json @@ -0,0 +1,29 @@ +{ + "vertex": "hollowengine:vfx/force_field", + "fragment": "hollowengine:vfx/force_field", + "attributes": [ + "Position", + "UV0", + "Normal", + "InstanceX", + "InstanceY", + "InstanceZ", + "InstanceOrigin", + "InstanceColor", + "InstanceUv", + "InstanceLight" + ], + "samplers": [ + { "name": "Sampler0" }, + { "name": "SceneDepth" } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ScreenSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "GameTime", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "FieldColor", "type": "float", "count": 4, "values": [ 0.35, 0.7, 1.0, 1.0 ] }, + { "name": "FresnelPower", "type": "float", "count": 1, "values": [ 2.5 ] }, + { "name": "IntersectionWidth", "type": "float", "count": 1, "values": [ 0.35 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.vsh new file mode 100644 index 000000000..06f01ac6a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/force_field.vsh @@ -0,0 +1,31 @@ +#version 150 + +in vec3 Position; +in vec2 UV0; +in vec3 Normal; + +in vec3 InstanceX; +in vec3 InstanceY; +in vec3 InstanceZ; +in vec3 InstanceOrigin; +in vec4 InstanceColor; +in vec4 InstanceUv; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; + +out vec3 viewPosition; +out vec3 viewNormal; +out vec4 vertexColor; +out vec2 texCoord0; + +void main() { + mat3 basis = mat3(InstanceX, InstanceY, InstanceZ); + vec4 view = ModelViewMat * vec4(InstanceOrigin + basis * Position, 1.0); + + viewPosition = view.xyz; + viewNormal = mat3(ModelViewMat) * normalize(basis * Normal); + vertexColor = InstanceColor; + texCoord0 = InstanceUv.xy + UV0 * InstanceUv.zw; + gl_Position = ProjMat * view; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/downsample.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/downsample.fsh new file mode 100644 index 000000000..f7ba3264f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/downsample.fsh @@ -0,0 +1,53 @@ +#version 150 + +uniform sampler2D Source; +uniform vec2 SourceTexel; +uniform float FirstPass; + +in vec2 texCoord; + +out vec4 fragColor; + +vec3 tap(float x, float y) { + return texture(Source, texCoord + vec2(x, y) * SourceTexel).rgb; +} + +float karis(vec3 color) { + return 1.0 / (1.0 + max(max(color.r, color.g), color.b)); +} + +void main() { + vec3 a = tap(-2.0, 2.0); + vec3 b = tap(0.0, 2.0); + vec3 c = tap(2.0, 2.0); + vec3 d = tap(-2.0, 0.0); + vec3 e = tap(0.0, 0.0); + vec3 f = tap(2.0, 0.0); + vec3 g = tap(-2.0, -2.0); + vec3 h = tap(0.0, -2.0); + vec3 i = tap(2.0, -2.0); + vec3 j = tap(-1.0, 1.0); + vec3 k = tap(1.0, 1.0); + vec3 l = tap(-1.0, -1.0); + vec3 m = tap(1.0, -1.0); + + vec3 inner = (j + k + l + m) * 0.25; + vec3 topLeft = (a + b + d + e) * 0.25; + vec3 topRight = (b + c + e + f) * 0.25; + vec3 bottomLeft = (d + e + g + h) * 0.25; + vec3 bottomRight = (e + f + h + i) * 0.25; + + vec3 color; + if (FirstPass > 0.5) { + float wInner = 0.5 * karis(inner); + float wTopLeft = 0.125 * karis(topLeft); + float wTopRight = 0.125 * karis(topRight); + float wBottomLeft = 0.125 * karis(bottomLeft); + float wBottomRight = 0.125 * karis(bottomRight); + color = (inner * wInner + topLeft * wTopLeft + topRight * wTopRight + bottomLeft * wBottomLeft + + bottomRight * wBottomRight) / (wInner + wTopLeft + wTopRight + wBottomLeft + wBottomRight); + } else { + color = inner * 0.5 + (topLeft + topRight + bottomLeft + bottomRight) * 0.125; + } + fragColor = vec4(color, 1.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/downsample.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/downsample.json new file mode 100644 index 000000000..79472dee0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/downsample.json @@ -0,0 +1,12 @@ +{ + "vertex": "hollowengine:vfx/post/screen", + "fragment": "hollowengine:vfx/glow/downsample", + "attributes": [ "Position", "UV0" ], + "samplers": [ + { "name": "Source" } + ], + "uniforms": [ + { "name": "SourceTexel", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "FirstPass", "type": "float", "count": 1, "values": [ 0.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/upsample.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/upsample.fsh new file mode 100644 index 000000000..204607626 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/upsample.fsh @@ -0,0 +1,20 @@ +#version 150 + +uniform sampler2D Source; +uniform vec2 SourceTexel; +uniform float Strength; + +in vec2 texCoord; + +out vec4 fragColor; + +vec3 tap(float x, float y) { + return texture(Source, texCoord + vec2(x, y) * SourceTexel).rgb; +} + +void main() { + vec3 color = tap(0.0, 0.0) * 4.0 + + (tap(-1.0, 0.0) + tap(1.0, 0.0) + tap(0.0, -1.0) + tap(0.0, 1.0)) * 2.0 + + tap(-1.0, -1.0) + tap(1.0, -1.0) + tap(-1.0, 1.0) + tap(1.0, 1.0); + fragColor = vec4(color / 16.0 * Strength, 0.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/upsample.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/upsample.json new file mode 100644 index 000000000..a6bbc5a49 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/glow/upsample.json @@ -0,0 +1,12 @@ +{ + "vertex": "hollowengine:vfx/post/screen", + "fragment": "hollowengine:vfx/glow/upsample", + "attributes": [ "Position", "UV0" ], + "samplers": [ + { "name": "Source" } + ], + "uniforms": [ + { "name": "SourceTexel", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "Strength", "type": "float", "count": 1, "values": [ 1.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/chromatic_aberration.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/chromatic_aberration.fsh new file mode 100644 index 000000000..0da3d310e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/chromatic_aberration.fsh @@ -0,0 +1,18 @@ +#version 150 + +uniform sampler2D SceneColor; +uniform vec2 ScreenSize; +uniform float Strength; + +in vec2 texCoord; + +out vec4 fragColor; + +void main() { + vec2 fromCenter = texCoord - 0.5; + vec2 shift = fromCenter * Strength * 12.0 / 1080.0 * vec2(ScreenSize.y / ScreenSize.x, 1.0) * 2.0; + float red = texture(SceneColor, texCoord + shift).r; + float green = texture(SceneColor, texCoord).g; + float blue = texture(SceneColor, texCoord - shift).b; + fragColor = vec4(red, green, blue, 1.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/chromatic_aberration.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/chromatic_aberration.json new file mode 100644 index 000000000..81f4786d6 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/chromatic_aberration.json @@ -0,0 +1,12 @@ +{ + "vertex": "hollowengine:vfx/post/screen", + "fragment": "hollowengine:vfx/post/chromatic_aberration", + "attributes": [ "Position", "UV0" ], + "samplers": [ + { "name": "SceneColor" } + ], + "uniforms": [ + { "name": "ScreenSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "Strength", "type": "float", "count": 1, "values": [ 1.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/grayscale.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/grayscale.fsh new file mode 100644 index 000000000..857559a68 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/grayscale.fsh @@ -0,0 +1,14 @@ +#version 150 + +uniform sampler2D SceneColor; +uniform float Strength; + +in vec2 texCoord; + +out vec4 fragColor; + +void main() { + vec3 color = texture(SceneColor, texCoord).rgb; + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + fragColor = vec4(mix(color, vec3(luma), clamp(Strength, 0.0, 1.0)), 1.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/grayscale.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/grayscale.json new file mode 100644 index 000000000..fb8e66a93 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/grayscale.json @@ -0,0 +1,11 @@ +{ + "vertex": "hollowengine:vfx/post/screen", + "fragment": "hollowengine:vfx/post/grayscale", + "attributes": [ "Position", "UV0" ], + "samplers": [ + { "name": "SceneColor" } + ], + "uniforms": [ + { "name": "Strength", "type": "float", "count": 1, "values": [ 1.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/impact.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/impact.fsh new file mode 100644 index 000000000..a211ebbdf --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/impact.fsh @@ -0,0 +1,18 @@ +#version 150 + +uniform sampler2D SceneColor; +uniform float Strength; +uniform float Threshold; +uniform float Invert; + +in vec2 texCoord; + +out vec4 fragColor; + +void main() { + vec3 color = texture(SceneColor, texCoord).rgb; + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + float ink = step(Threshold, luma); + ink = mix(ink, 1.0 - ink, step(0.5, Invert)); + fragColor = vec4(mix(color, vec3(ink), clamp(Strength, 0.0, 1.0)), 1.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/impact.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/impact.json new file mode 100644 index 000000000..9b7c2e986 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/impact.json @@ -0,0 +1,13 @@ +{ + "vertex": "hollowengine:vfx/post/screen", + "fragment": "hollowengine:vfx/post/impact", + "attributes": [ "Position", "UV0" ], + "samplers": [ + { "name": "SceneColor" } + ], + "uniforms": [ + { "name": "Strength", "type": "float", "count": 1, "values": [ 1.0 ] }, + { "name": "Threshold", "type": "float", "count": 1, "values": [ 0.35 ] }, + { "name": "Invert", "type": "float", "count": 1, "values": [ 0.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/screen.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/screen.vsh new file mode 100644 index 000000000..3501fd780 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/post/screen.vsh @@ -0,0 +1,11 @@ +#version 150 + +in vec3 Position; +in vec2 UV0; + +out vec2 texCoord; + +void main() { + gl_Position = vec4(Position.xy, 0.0, 1.0); + texCoord = UV0; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/shockwave.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/shockwave.fsh new file mode 100644 index 000000000..3e73f4168 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/shockwave.fsh @@ -0,0 +1,97 @@ +#version 150 + +uniform vec3 SkyCenter; +uniform vec3 NodeOffset; +uniform float EffectTime; + +uniform float Radius; +uniform float Width; +uniform float Opacity; +uniform float Stars; +uniform vec4 SkyColor; +uniform vec4 WaveColor; +uniform vec4 StarColor; + +in vec3 skyDirection; + +out vec4 fragColor; + +float hash13(vec3 p) { + p = fract(p * 0.1031); + p += dot(p, p.zyx + 31.32); + return fract((p.x + p.y) * p.z); +} + +vec3 hash33(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.xxy + p.yxx) * p.zyx); +} + +float valueNoise(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(hash13(i), hash13(i + vec3(1, 0, 0)), f.x), mix(hash13(i + vec3(0, 1, 0)), hash13(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash13(i + vec3(0, 0, 1)), hash13(i + vec3(1, 0, 1)), f.x), mix(hash13(i + vec3(0, 1, 1)), hash13(i + vec3(1, 1, 1)), f.x), f.y), + f.z + ); +} + +float fbm(vec3 p) { + float sum = 0.0; + float amplitude = 0.5; + for (int octave = 0; octave < 4; octave++) { + sum += amplitude * valueNoise(p); + p = p * 2.03 + 17.1; + amplitude *= 0.5; + } + return sum; +} + +float starLayer(vec3 dir, float radius, float density, float coverage, float seed) { + float b = dot(dir, NodeOffset); + float h = b * b - dot(NodeOffset, NodeOffset) + radius * radius; + if (h < 0.0) return 0.0; + vec3 onSphere = (dir * (b + sqrt(h)) - NodeOffset) / radius; + + vec3 p = onSphere * density; + vec3 cell = floor(p); + // Kept away from the cell walls, so no star is cut in half by a neighbor cell. + vec3 star = 0.25 + 0.5 * hash33(cell + seed); + float present = step(1.0 - coverage, hash13(cell + seed * 1.7)); + float size = mix(0.07, 0.2, hash13(cell + seed * 3.1)); + float glow = pow(clamp(1.0 - length(fract(p) - star) / size, 0.0, 1.0), 2.5); + float twinkle = 0.65 + 0.35 * sin(EffectTime * mix(1.5, 4.0, hash13(cell + 9.0)) + hash13(cell) * 6.2831); + return present * glow * twinkle; +} + +void main() { + vec3 dir = normalize(skyDirection); + float angle = degrees(acos(clamp(dot(dir, SkyCenter), -1.0, 1.0))); + float front = max(Radius, 0.0); + float edge = max(Width, 0.001); + float started = step(0.0001, front); + + float inside = (1.0 - smoothstep(front - edge, front, angle)) * started; + float ring = exp(-pow((angle - front) / (edge * 0.5), 2.0)) * started; + if (inside <= 0.0 && ring < 0.002) discard; + + float haze = fbm(dir * 2.5 + vec3(0.0, EffectTime * 0.05, 0.0)); + vec3 fill = SkyColor.rgb * (0.7 + 0.6 * haze); + fill += SkyColor.rgb * exp(-angle / 20.0) * 0.6; + fill += WaveColor.rgb * smoothstep(front - edge * 5.0, front, angle) * 0.35; + + float starLight = starLayer(dir, 2048.0, 70.0, 0.55, 1.0) + + starLayer(dir, 700.0, 45.0, 0.35, 2.0) + + starLayer(dir, 300.0, 28.0, 0.2, 3.0); + starLight *= max(Stars, 0.0); + fill += StarColor.rgb * starLight; + + float fillAlpha = clamp(inside * max(SkyColor.a, starLight * StarColor.a), 0.0, 1.0); + float ringAlpha = clamp(ring * WaveColor.a, 0.0, 1.0); + float alpha = ringAlpha + fillAlpha * (1.0 - ringAlpha); + vec3 color = (WaveColor.rgb * ringAlpha + fill * fillAlpha * (1.0 - ringAlpha)) / max(alpha, 0.0001); + fragColor = vec4(color, alpha * clamp(Opacity, 0.0, 1.0)); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/shockwave.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/shockwave.json new file mode 100644 index 000000000..c609e9716 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/shockwave.json @@ -0,0 +1,19 @@ +{ + "vertex": "hollowengine:vfx/sky/sky", + "fragment": "hollowengine:vfx/sky/shockwave", + "attributes": [ "Position" ], + "samplers": [], + "uniforms": [ + { "name": "InvViewProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "SkyCenter", "type": "float", "count": 3, "values": [ 0.0, 1.0, 0.0 ] }, + { "name": "NodeOffset", "type": "float", "count": 3, "values": [ 0.0, 200.0, 0.0 ] }, + { "name": "EffectTime", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "Radius", "type": "float", "count": 1, "values": [ 60.0 ] }, + { "name": "Width", "type": "float", "count": 1, "values": [ 6.0 ] }, + { "name": "Opacity", "type": "float", "count": 1, "values": [ 1.0 ] }, + { "name": "Stars", "type": "float", "count": 1, "values": [ 1.0 ] }, + { "name": "SkyColor", "type": "float", "count": 4, "values": [ 0.98, 0.68, 0.26, 0.85 ] }, + { "name": "WaveColor", "type": "float", "count": 4, "values": [ 1.0, 0.9, 0.62, 1.0 ] }, + { "name": "StarColor", "type": "float", "count": 4, "values": [ 1.0, 0.95, 0.82, 1.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/sky.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/sky.vsh new file mode 100644 index 000000000..3f530161f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx/sky/sky.vsh @@ -0,0 +1,14 @@ +#version 150 + +in vec3 Position; + +uniform mat4 InvViewProjMat; + +out vec3 skyDirection; + +void main() { + vec4 far = InvViewProjMat * vec4(Position.xy, 1.0, 1.0); + vec4 near = InvViewProjMat * vec4(Position.xy, -1.0, 1.0); + skyDirection = far.xyz / far.w - near.xyz / near.w; + gl_Position = vec4(Position.xy, 1.0, 1.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.fsh new file mode 100644 index 000000000..59aa75244 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.fsh @@ -0,0 +1,45 @@ +#version 150 + +#moj_import +#moj_import + +uniform sampler2D Sampler0; +uniform sampler2D SceneDepth; + +uniform mat4 ProjMat; +uniform vec4 ColorModulator; +uniform float FogStart; +uniform float FogEnd; +uniform float BlendMode; +uniform float Softness; +uniform float Glow; +uniform float GlowPass; +uniform vec2 ScreenSize; + +in vec4 vertexColor; +in vec2 texCoord0; +in float vertexDistance; + +out vec4 fragColor; + +void main() { + vec4 color = texture(Sampler0, texCoord0) * vertexColor * ColorModulator; + int mode = int(BlendMode + 0.5); + + if (mode == 2) { + if (color.a < 0.5) discard; + color.a = 1.0; + } else if (color.a < 0.01) { + discard; + } + + color.a *= linear_fog_fade(vertexDistance, FogStart, FogEnd) + * hollowengine_soft_fade(SceneDepth, ScreenSize, ProjMat, Softness); + + if (GlowPass > 0.5) { + if (mode == 3 || Glow <= 0.0) discard; + fragColor = vec4(color.rgb * color.a * Glow, 0.0); + } else { + fragColor = color; + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.json new file mode 100644 index 000000000..8a80f0fd1 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.json @@ -0,0 +1,40 @@ +{ + "blend": { + "func": "add", + "srcrgb": "srcalpha", + "dstrgb": "1-srcalpha" + }, + "vertex": "hollowengine:vfx_mesh", + "fragment": "hollowengine:vfx_mesh", + "attributes": [ + "Position", + "UV0", + "Normal", + "InstanceX", + "InstanceY", + "InstanceZ", + "InstanceOrigin", + "InstanceColor", + "InstanceUv", + "InstanceLight" + ], + "samplers": [ + { "name": "Sampler0" }, + { "name": "Sampler2" }, + { "name": "SceneDepth" } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { "name": "FogStart", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "FogEnd", "type": "float", "count": 1, "values": [ 1.0 ] }, + { "name": "FogShape", "type": "int", "count": 1, "values": [ 0 ] }, + { "name": "Shaded", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "BlendMode", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "Softness", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "Glow", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "GlowPass", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "ScreenSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.vsh new file mode 100644 index 000000000..270b9ecc7 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_mesh.vsh @@ -0,0 +1,46 @@ +#version 150 + +in vec3 Position; +in vec2 UV0; +in vec3 Normal; + +in vec3 InstanceX; +in vec3 InstanceY; +in vec3 InstanceZ; +in vec3 InstanceOrigin; +in vec4 InstanceColor; +in vec4 InstanceUv; +in vec2 InstanceLight; + +uniform sampler2D Sampler2; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform int FogShape; +uniform float Shaded; + +out vec4 vertexColor; +out vec2 texCoord0; +out float vertexDistance; + +float view_fog_distance(vec3 viewPos, int fogShape) { + if (fogShape == 0) { + return length(viewPos); + } + return max(length(viewPos.xz), abs(viewPos.y)); +} + +void main() { + mat3 basis = mat3(InstanceX, InstanceY, InstanceZ); + vec3 position = InstanceOrigin + basis * Position; + vec3 normal = normalize(basis * Normal); + + vec4 viewPos = ModelViewMat * vec4(position, 1.0); + gl_Position = ProjMat * viewPos; + vertexDistance = view_fog_distance(viewPos.xyz, FogShape); + + float shade = mix(1.0, 0.72 + 0.28 * (normal.y * 0.5 + 0.5), Shaded); + vec4 light = texelFetch(Sampler2, ivec2(InstanceLight) / 16, 0); + vertexColor = InstanceColor * vec4(light.rgb * shade, light.a); + texCoord0 = InstanceUv.xy + UV0 * InstanceUv.zw; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.fsh new file mode 100644 index 000000000..1cce9dca6 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.fsh @@ -0,0 +1,48 @@ +#version 150 + +#moj_import +#moj_import + +uniform sampler2D Sampler0; +uniform sampler2D SceneDepth; + +uniform mat4 ProjMat; +uniform vec4 ColorModulator; +uniform float FogStart; +uniform float FogEnd; +uniform float AlphaCutoff; +uniform vec2 ScreenSize; +uniform float GlowPass; + +in vec4 vertexColor; +in vec2 texCoord0; +in float vertexDistance; +flat in int blendMode; +flat in vec2 material; + +out vec4 fragColor; + +void main() { + vec4 color = texture(Sampler0, texCoord0) * vertexColor * ColorModulator; + + if (blendMode == 2) { + if (color.a < 0.5) discard; + color.a = 1.0; + } else if (color.a < AlphaCutoff) { + discard; + } + + float strength = color.a * linear_fog_fade(vertexDistance, FogStart, FogEnd) + * hollowengine_soft_fade(SceneDepth, ScreenSize, ProjMat, material.x); + + if (GlowPass > 0.5) { + if (blendMode == 3 || material.y <= 0.0) discard; + fragColor = vec4(color.rgb * strength * material.y, 0.0); + } else if (blendMode == 3) { + fragColor = vec4(mix(vec3(1.0), color.rgb, strength), 1.0); + } else if (blendMode == 1) { + fragColor = vec4(color.rgb * strength, 0.0); + } else { + fragColor = vec4(color.rgb * strength, strength); + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.json new file mode 100644 index 000000000..c339d1251 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.json @@ -0,0 +1,37 @@ +{ + "blend": { + "func": "add", + "srcrgb": "srcalpha", + "dstrgb": "1-srcalpha" + }, + "vertex": "hollowengine:vfx_particle", + "fragment": "hollowengine:vfx_particle", + "attributes": [ + "Position", + "UV0", + "InstanceCenter", + "InstanceRight", + "InstanceUp", + "InstanceColor", + "InstanceUv", + "InstanceLight", + "InstanceBlend", + "InstanceMaterial" + ], + "samplers": [ + { "name": "Sampler0" }, + { "name": "Sampler2" }, + { "name": "SceneDepth" } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { "name": "FogStart", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "FogEnd", "type": "float", "count": 1, "values": [ 1.0 ] }, + { "name": "FogShape", "type": "int", "count": 1, "values": [ 0 ] }, + { "name": "AlphaCutoff", "type": "float", "count": 1, "values": [ 0.01 ] }, + { "name": "ScreenSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] }, + { "name": "GlowPass", "type": "float", "count": 1, "values": [ 0.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.vsh new file mode 100644 index 000000000..186dbd9f3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_particle.vsh @@ -0,0 +1,44 @@ +#version 150 + +in vec3 Position; +in vec2 UV0; + +in vec3 InstanceCenter; +in vec3 InstanceRight; +in vec3 InstanceUp; +in vec4 InstanceColor; +in vec4 InstanceUv; +in vec2 InstanceLight; +in float InstanceBlend; +in vec2 InstanceMaterial; + +uniform sampler2D Sampler2; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform int FogShape; + +out vec4 vertexColor; +out vec2 texCoord0; +out float vertexDistance; +flat out int blendMode; +flat out vec2 material; + +float view_fog_distance(vec3 viewPos, int fogShape) { + if (fogShape == 0) { + return length(viewPos); + } + return max(length(viewPos.xz), abs(viewPos.y)); +} + +void main() { + vec3 position = InstanceCenter + InstanceRight * Position.x + InstanceUp * Position.y; + + vec4 viewPos = ModelViewMat * vec4(position, 1.0); + gl_Position = ProjMat * viewPos; + vertexDistance = view_fog_distance(viewPos.xyz, FogShape); + vertexColor = InstanceColor * texelFetch(Sampler2, ivec2(InstanceLight) / 16, 0); + texCoord0 = InstanceUv.xy + UV0 * InstanceUv.zw; + blendMode = int(InstanceBlend + 0.5); + material = InstanceMaterial; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.fsh new file mode 100644 index 000000000..59aa75244 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.fsh @@ -0,0 +1,45 @@ +#version 150 + +#moj_import +#moj_import + +uniform sampler2D Sampler0; +uniform sampler2D SceneDepth; + +uniform mat4 ProjMat; +uniform vec4 ColorModulator; +uniform float FogStart; +uniform float FogEnd; +uniform float BlendMode; +uniform float Softness; +uniform float Glow; +uniform float GlowPass; +uniform vec2 ScreenSize; + +in vec4 vertexColor; +in vec2 texCoord0; +in float vertexDistance; + +out vec4 fragColor; + +void main() { + vec4 color = texture(Sampler0, texCoord0) * vertexColor * ColorModulator; + int mode = int(BlendMode + 0.5); + + if (mode == 2) { + if (color.a < 0.5) discard; + color.a = 1.0; + } else if (color.a < 0.01) { + discard; + } + + color.a *= linear_fog_fade(vertexDistance, FogStart, FogEnd) + * hollowengine_soft_fade(SceneDepth, ScreenSize, ProjMat, Softness); + + if (GlowPass > 0.5) { + if (mode == 3 || Glow <= 0.0) discard; + fragColor = vec4(color.rgb * color.a * Glow, 0.0); + } else { + fragColor = color; + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.json b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.json new file mode 100644 index 000000000..f4b93d995 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.json @@ -0,0 +1,33 @@ +{ + "blend": { + "func": "add", + "srcrgb": "srcalpha", + "dstrgb": "1-srcalpha" + }, + "vertex": "hollowengine:vfx_ribbon", + "fragment": "hollowengine:vfx_ribbon", + "attributes": [ + "Position", + "UV0", + "Color", + "UV2" + ], + "samplers": [ + { "name": "Sampler0" }, + { "name": "Sampler2" }, + { "name": "SceneDepth" } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { "name": "FogStart", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "FogEnd", "type": "float", "count": 1, "values": [ 1.0 ] }, + { "name": "FogShape", "type": "int", "count": 1, "values": [ 0 ] }, + { "name": "BlendMode", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "Softness", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "Glow", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "GlowPass", "type": "float", "count": 1, "values": [ 0.0 ] }, + { "name": "ScreenSize", "type": "float", "count": 2, "values": [ 1.0, 1.0 ] } + ] +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.vsh new file mode 100644 index 000000000..69505db74 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/core/vfx_ribbon.vsh @@ -0,0 +1,31 @@ +#version 150 + +in vec3 Position; +in vec2 UV0; +in vec4 Color; +in ivec2 UV2; + +uniform sampler2D Sampler2; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform int FogShape; + +out vec4 vertexColor; +out vec2 texCoord0; +out float vertexDistance; + +float view_fog_distance(vec3 viewPos, int fogShape) { + if (fogShape == 0) { + return length(viewPos); + } + return max(length(viewPos.xz), abs(viewPos.y)); +} + +void main() { + vec4 viewPos = ModelViewMat * vec4(Position, 1.0); + gl_Position = ProjMat * viewPos; + vertexDistance = view_fog_distance(viewPos.xyz, FogShape); + vertexColor = Color * texelFetch(Sampler2, UV2 / 16, 0); + texCoord0 = UV0; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/mesh.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/mesh.vsh new file mode 100644 index 000000000..c0c3510c9 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/mesh.vsh @@ -0,0 +1,81 @@ +#version 150 + +// The vertex half of a graph drawn on the built-in meshes: the instance attributes of the engine's +// mesh program, the engine inputs a graph reads, then the graph's vertex code. + +in vec3 Position; +in vec2 UV0; +in vec3 Normal; + +in vec3 InstanceX; +in vec3 InstanceY; +in vec3 InstanceZ; +in vec3 InstanceOrigin; +in vec4 InstanceColor; +in vec4 InstanceUv; +in vec2 InstanceLight; + +uniform sampler2D Sampler0; +uniform sampler2D Sampler2; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform int FogShape; +uniform float Shaded; +uniform float GameTime; +//#uniforms + +out vec4 vertexColor; +out vec2 texCoord0; +out vec2 localUv; +out vec3 worldPosition; +out vec3 faceNormal; +out vec3 objectPosition; +out float vertexDistance; +flat out vec3 eyePosition; +flat out vec4 frameWindow; + +vec2 sg_frame_uv(vec2 uv) { + return InstanceUv.xy + uv * InstanceUv.zw; +} + +//#functions + +float view_fog_distance(vec3 viewPos, int fogShape) { + if (fogShape == 0) { + return length(viewPos); + } + return max(length(viewPos.xz), abs(viewPos.y)); +} + +void main() { + vec3 eye = (inverse(ModelViewMat) * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + mat3 basis = mat3(InstanceX, InstanceY, InstanceZ); + vec3 sg_position = InstanceOrigin + basis * Position; + // The built-in meshes span -0.5 to 0.5; the graph sees -1 to 1, as on a particle and in the previews. + vec3 sg_object_position = Position * 2.0; + vec3 sg_normal = normalize(basis * Normal); + vec2 sg_uv = UV0; + vec2 sg_texture_uv = sg_frame_uv(UV0); + float shade = mix(1.0, 0.72 + 0.28 * (sg_normal.y * 0.5 + 0.5), Shaded); + vec4 light = texelFetch(Sampler2, ivec2(InstanceLight) / 16, 0); + vec4 sg_color = InstanceColor * vec4(light.rgb * shade, light.a); + float sg_time = GameTime * 1200.0; + vec3 sg_view_direction = normalize(eye - sg_position); + vec3 sg_out_vertex_offset = vec3(0.0); + + //#vertex + + vec3 position = sg_position + sg_out_vertex_offset; + vec4 viewPos = ModelViewMat * vec4(position, 1.0); + gl_Position = ProjMat * viewPos; + vertexDistance = view_fog_distance(viewPos.xyz, FogShape); + vertexColor = sg_color; + texCoord0 = sg_texture_uv; + localUv = UV0; + worldPosition = position; + faceNormal = sg_normal; + objectPosition = sg_object_position; + eyePosition = eye; + frameWindow = InstanceUv; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/particle.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/particle.fsh new file mode 100644 index 000000000..2afb9a25e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/particle.fsh @@ -0,0 +1,98 @@ +#version 150 + +// The fragment half of a graph drawn on particle quads. After the graph has said what the surface +// is, this is the engine's particle program: blend modes, fog, the soft edge and the glow pass. + +#moj_import +#moj_import + +uniform sampler2D Sampler0; +uniform sampler2D SceneDepth; +uniform sampler2D SceneColor; + +uniform mat4 ProjMat; +uniform vec4 ColorModulator; +uniform float FogStart; +uniform float FogEnd; +uniform float AlphaCutoff; +uniform vec2 ScreenSize; +uniform float GlowPass; +uniform float GameTime; +//#uniforms + +in vec4 vertexColor; +in vec2 texCoord0; +in vec2 localUv; +in vec3 worldPosition; +in vec3 faceNormal; +in float vertexDistance; +flat in vec3 eyePosition; +flat in int blendMode; +// Softness in blocks, then glow. +flat in vec2 material; +// Where the flipbook frame of this particle sits in the texture: corner, then size. +flat in vec4 frameWindow; + +out vec4 fragColor; + +float sg_scene_depth() { + return hollowengine_view_depth(ProjMat, texture(SceneDepth, gl_FragCoord.xy / ScreenSize).r); +} + +float sg_fragment_depth() { + return hollowengine_view_depth(ProjMat, gl_FragCoord.z); +} + +vec3 sg_scene_color(vec2 uv) { + return texture(SceneColor, uv).rgb; +} + +vec2 sg_frame_uv(vec2 uv) { + return frameWindow.xy + uv * frameWindow.zw; +} + +//#functions + +void main() { + vec2 sg_uv = localUv; + vec3 sg_object_position = vec3(sg_uv * 2.0 - 1.0, 0.0); + vec2 sg_texture_uv = texCoord0; + vec4 sg_color = vertexColor * ColorModulator; + float sg_time = GameTime * 1200.0; + vec3 sg_position = worldPosition; + vec3 sg_normal = normalize(faceNormal); + vec3 sg_view_direction = normalize(eyePosition - worldPosition); + vec2 sg_screen_uv = gl_FragCoord.xy / ScreenSize; + + vec3 sg_out_color = vec3(1.0); + float sg_out_alpha = 1.0; + vec3 sg_out_emission = vec3(0.0); + float sg_out_alpha_clip = 0.0; + bool sg_has_emission = false; + + //#fragment + + vec4 color = vec4(sg_out_color, sg_out_alpha); + if (color.a < sg_out_alpha_clip) discard; + if (blendMode == 2) { + if (color.a < 0.5) discard; + color.a = 1.0; + } else if (color.a < AlphaCutoff) { + discard; + } + + float strength = color.a * linear_fog_fade(vertexDistance, FogStart, FogEnd) + * hollowengine_soft_fade(SceneDepth, ScreenSize, ProjMat, material.x); + + if (GlowPass > 0.5) { + if (blendMode == 3 || material.y <= 0.0) discard; + vec3 glow = sg_has_emission ? sg_out_emission : color.rgb; + fragColor = vec4(glow * strength * material.y, 0.0); + } else if (blendMode == 3) { + fragColor = vec4(mix(vec3(1.0), color.rgb, strength), 1.0); + } else if (blendMode == 1) { + fragColor = vec4(color.rgb * strength, 0.0); + } else { + fragColor = vec4(color.rgb * strength, strength); + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/particle.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/particle.vsh new file mode 100644 index 000000000..26518a1d6 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/particle.vsh @@ -0,0 +1,78 @@ +#version 150 + +// The vertex half of a graph drawn on particle quads: the instance attributes of the engine's +// particle program, the engine inputs a graph reads, then the graph's vertex code. + +in vec3 Position; +in vec2 UV0; + +in vec3 InstanceCenter; +in vec3 InstanceRight; +in vec3 InstanceUp; +in vec4 InstanceColor; +in vec4 InstanceUv; +in vec2 InstanceLight; +in float InstanceBlend; +in vec2 InstanceMaterial; + +uniform sampler2D Sampler0; +uniform sampler2D Sampler2; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform int FogShape; +uniform float GameTime; +//#uniforms + +out vec4 vertexColor; +out vec2 texCoord0; +out vec2 localUv; +out vec3 worldPosition; +out vec3 faceNormal; +out float vertexDistance; +flat out vec3 eyePosition; +flat out int blendMode; +flat out vec2 material; +flat out vec4 frameWindow; + +vec2 sg_frame_uv(vec2 uv) { + return InstanceUv.xy + uv * InstanceUv.zw; +} + +//#functions + +float view_fog_distance(vec3 viewPos, int fogShape) { + if (fogShape == 0) { + return length(viewPos); + } + return max(length(viewPos.xz), abs(viewPos.y)); +} + +void main() { + vec3 eye = (inverse(ModelViewMat) * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + vec3 sg_position = InstanceCenter + InstanceRight * Position.x + InstanceUp * Position.y; + vec2 sg_uv = UV0; + vec3 sg_object_position = vec3(sg_uv * 2.0 - 1.0, 0.0); + vec2 sg_texture_uv = InstanceUv.xy + UV0 * InstanceUv.zw; + vec4 sg_color = InstanceColor * texelFetch(Sampler2, ivec2(InstanceLight) / 16, 0); + float sg_time = GameTime * 1200.0; + vec3 sg_normal = normalize(cross(InstanceRight, InstanceUp)); + vec3 sg_view_direction = normalize(eye - sg_position); + vec3 sg_out_vertex_offset = vec3(0.0); + + //#vertex + + vec3 position = sg_position + sg_out_vertex_offset; + vec4 viewPos = ModelViewMat * vec4(position, 1.0); + gl_Position = ProjMat * viewPos; + vertexDistance = view_fog_distance(viewPos.xyz, FogShape); + vertexColor = sg_color; + texCoord0 = sg_texture_uv; + localUv = UV0; + worldPosition = position; + faceNormal = sg_normal; + eyePosition = eye; + blendMode = int(InstanceBlend + 0.5); + material = InstanceMaterial; + frameWindow = InstanceUv; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/preview.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/preview.fsh new file mode 100644 index 000000000..df38e78ab --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/preview.fsh @@ -0,0 +1,96 @@ +#version 150 + +// Every node of a graph in one program: PreviewNode picks whose value this draw shows. There is no +// scene here, so the functions that read one stand in with something that still shows a change. +// Whatever has alpha is shown over a checkerboard, so what is see-through looks it. + +uniform sampler2D Sampler0; +uniform int PreviewNode; +uniform float PreviewTime; +// 1 on the flat quad, where the view looks straight at the surface. +uniform float PreviewFlat; +// The side of the target, in pixels. +uniform float PreviewPixels; +//#uniforms + +in vec2 texCoord; +in vec3 relativePosition; +in vec3 worldNormal; +in vec3 objectPosition; + +out vec4 fragColor; + +float sg_scene_depth() { + return 8.0; +} + +float sg_fragment_depth() { + return 4.0 + texCoord.y * 4.0; +} + +vec3 sg_scene_color(vec2 uv) { + vec2 cell = floor(uv * 8.0); + return mix(vec3(0.16), vec3(0.3), mod(cell.x + cell.y, 2.0)); +} + +vec2 sg_frame_uv(vec2 uv) { + return uv; +} + +vec3 sg_checker() { + vec2 cell = floor(gl_FragCoord.xy / 8.0); + return mix(vec3(0.17), vec3(0.24), mod(cell.x + cell.y, 2.0)); +} + +vec4 sg_show(float value) { + return vec4(vec3(value), 1.0); +} + +vec4 sg_show(vec2 value) { + return vec4(value, 0.0, 1.0); +} + +vec4 sg_show(vec3 value) { + return vec4(value, 1.0); +} + +// A direction, -1 to 1 on each axis, as a normal map shows one. +vec4 sg_show_signed(vec3 value) { + return vec4(value * 0.5 + 0.5, 1.0); +} + +// The mesh as an offset moved it, lit from above so its shape shows. +vec4 sg_show_shape() { + vec3 normal = normalize(cross(dFdx(relativePosition), dFdy(relativePosition))); + if (dot(normal, relativePosition) > 0.0) normal = -normal; + float light = 0.35 + 0.65 * clamp(dot(normal, normalize(vec3(0.4, 1.0, 0.3))), 0.0, 1.0); + return vec4(vec3(light), 1.0); +} + +vec4 sg_show(vec4 value) { + return vec4(mix(sg_checker(), value.rgb, clamp(value.a, 0.0, 1.0)), 1.0); +} + +// In game emission reaches the frame through the glow pass, blurred; there is no blur here, so it is +// added where the surface is, as much as the surface is there. +vec4 sg_surface(vec3 color, float alpha, vec3 emission, float clip) { + if (alpha < clip) discard; + float coverage = clamp(alpha, 0.0, 1.0); + return vec4(mix(sg_checker(), color, coverage) + emission * coverage, 1.0); +} + +//#functions + +void main() { + vec2 sg_uv = texCoord; + vec2 sg_texture_uv = texCoord; + vec4 sg_color = vec4(1.0); + float sg_time = PreviewTime; + vec3 sg_position = relativePosition; + vec3 sg_object_position = objectPosition; + vec3 sg_normal = normalize(worldNormal); + vec3 sg_view_direction = PreviewFlat > 0.5 ? vec3(0.0, 0.0, 1.0) : normalize(-relativePosition); + vec2 sg_screen_uv = gl_FragCoord.xy / PreviewPixels; + + //#preview +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/preview.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/preview.vsh new file mode 100644 index 000000000..9c93d2321 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/preview.vsh @@ -0,0 +1,51 @@ +#version 150 + +// The previews of a graph: a flat quad over the whole target, or a mesh turned by PreviewModel and +// seen by a small camera. Positions and normals are in the axes of the world, the position relative +// to the camera, as in game. The graph's vertex code runs for the previews that move the mesh: the +// output node's, by its vertex offset, and an offset node's, by what it computes. + +in vec3 Position; +in vec2 UV0; +in vec3 Normal; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform mat4 PreviewModel; +uniform float PreviewTime; +uniform int PreviewNode; +//#uniforms + +out vec2 texCoord; +out vec3 relativePosition; +out vec3 worldNormal; +out vec3 objectPosition; + +vec2 sg_frame_uv(vec2 uv) { + return uv; +} + +//#functions + +void main() { + vec3 eye = (inverse(ModelViewMat) * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + vec3 world = (PreviewModel * vec4(Position, 1.0)).xyz; + vec2 sg_uv = UV0; + vec2 sg_texture_uv = UV0; + vec3 sg_object_position = Position; + vec3 sg_normal = normalize(mat3(PreviewModel) * Normal); + vec3 sg_position = world - eye; + vec3 sg_view_direction = normalize(eye - world); + vec4 sg_color = vec4(1.0); + float sg_time = PreviewTime; + vec3 sg_out_vertex_offset = vec3(0.0); + + //#vertex + + vec3 moved = world + sg_out_vertex_offset; + gl_Position = ProjMat * ModelViewMat * vec4(moved, 1.0); + texCoord = UV0; + relativePosition = moved - eye; + worldNormal = sg_normal; + objectPosition = Position; +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/ribbon.vsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/ribbon.vsh new file mode 100644 index 000000000..20712b80c --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/ribbon.vsh @@ -0,0 +1,70 @@ +#version 150 + +// The vertex half of a graph drawn on trails and beams: the vanilla particle format the ribbons are +// built in, the engine inputs a graph reads, then the graph's vertex code. A ribbon has no frame of +// its own in the texture and faces the camera, so its normal is the direction toward the eye. + +in vec3 Position; +in vec2 UV0; +in vec4 Color; +in ivec2 UV2; + +uniform sampler2D Sampler0; +uniform sampler2D Sampler2; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; +uniform int FogShape; +uniform float GameTime; +//#uniforms + +out vec4 vertexColor; +out vec2 texCoord0; +out vec2 localUv; +out vec3 worldPosition; +out vec3 faceNormal; +out vec3 objectPosition; +out float vertexDistance; +flat out vec3 eyePosition; +flat out vec4 frameWindow; + +vec2 sg_frame_uv(vec2 uv) { + return uv; +} + +//#functions + +float view_fog_distance(vec3 viewPos, int fogShape) { + if (fogShape == 0) { + return length(viewPos); + } + return max(length(viewPos.xz), abs(viewPos.y)); +} + +void main() { + vec3 eye = (inverse(ModelViewMat) * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + vec3 sg_position = Position; + vec2 sg_uv = UV0; + vec2 sg_texture_uv = UV0; + vec3 sg_object_position = vec3(UV0 * 2.0 - 1.0, 0.0); + vec4 sg_color = Color * texelFetch(Sampler2, UV2 / 16, 0); + float sg_time = GameTime * 1200.0; + vec3 sg_view_direction = normalize(eye - sg_position); + vec3 sg_normal = sg_view_direction; + vec3 sg_out_vertex_offset = vec3(0.0); + + //#vertex + + vec3 position = sg_position + sg_out_vertex_offset; + vec4 viewPos = ModelViewMat * vec4(position, 1.0); + gl_Position = ProjMat * viewPos; + vertexDistance = view_fog_distance(viewPos.xyz, FogShape); + vertexColor = sg_color; + texCoord0 = sg_texture_uv; + localUv = UV0; + worldPosition = position; + faceNormal = sg_normal; + objectPosition = sg_object_position; + eyePosition = eye; + frameWindow = vec4(0.0, 0.0, 1.0, 1.0); +} diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/graph/surface.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/graph/surface.fsh new file mode 100644 index 000000000..cbdd6c724 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/shaders/graph/surface.fsh @@ -0,0 +1,96 @@ +#version 150 + +// The fragment half of a graph drawn on meshes, trails and beams. After the graph has said what the +// surface is, this is the engine's mesh and ribbon program: one blend mode per draw, straight alpha, +// fog, the soft edge and the glow pass, which the emission feeds when the graph has one. + +#moj_import +#moj_import + +uniform sampler2D Sampler0; +uniform sampler2D SceneDepth; +uniform sampler2D SceneColor; + +uniform mat4 ProjMat; +uniform vec4 ColorModulator; +uniform float FogStart; +uniform float FogEnd; +uniform float BlendMode; +uniform float Softness; +uniform float Glow; +uniform float GlowPass; +uniform vec2 ScreenSize; +uniform float GameTime; +//#uniforms + +in vec4 vertexColor; +in vec2 texCoord0; +in vec2 localUv; +in vec3 worldPosition; +in vec3 faceNormal; +in vec3 objectPosition; +in float vertexDistance; +flat in vec3 eyePosition; +// Where the flipbook frame sits in the texture: corner, then size. +flat in vec4 frameWindow; + +out vec4 fragColor; + +float sg_scene_depth() { + return hollowengine_view_depth(ProjMat, texture(SceneDepth, gl_FragCoord.xy / ScreenSize).r); +} + +float sg_fragment_depth() { + return hollowengine_view_depth(ProjMat, gl_FragCoord.z); +} + +vec3 sg_scene_color(vec2 uv) { + return texture(SceneColor, uv).rgb; +} + +vec2 sg_frame_uv(vec2 uv) { + return frameWindow.xy + uv * frameWindow.zw; +} + +//#functions + +void main() { + vec2 sg_uv = localUv; + vec2 sg_texture_uv = texCoord0; + vec3 sg_object_position = objectPosition; + vec4 sg_color = vertexColor * ColorModulator; + float sg_time = GameTime * 1200.0; + vec3 sg_position = worldPosition; + vec3 sg_normal = normalize(faceNormal); + vec3 sg_view_direction = normalize(eyePosition - worldPosition); + vec2 sg_screen_uv = gl_FragCoord.xy / ScreenSize; + + vec3 sg_out_color = vec3(1.0); + float sg_out_alpha = 1.0; + vec3 sg_out_emission = vec3(0.0); + float sg_out_alpha_clip = 0.0; + bool sg_has_emission = false; + + //#fragment + + vec4 color = vec4(sg_out_color, sg_out_alpha); + int mode = int(BlendMode + 0.5); + if (color.a < sg_out_alpha_clip) discard; + if (mode == 2) { + if (color.a < 0.5) discard; + color.a = 1.0; + } else if (color.a < 0.01) { + discard; + } + + color.a *= linear_fog_fade(vertexDistance, FogStart, FogEnd) + * hollowengine_soft_fade(SceneDepth, ScreenSize, ProjMat, Softness); + + if (GlowPass > 0.5) { + if (mode == 3 || Glow <= 0.0) discard; + vec3 glow = sg_has_emission ? sg_out_emission : color.rgb; + fragColor = vec4(glow * color.a * Glow, 0.0); + } else { + fragColor = color; + } +} 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 afb97dcb7..c5f5afb51 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 @@ -51,33 +51,34 @@ float minimumSegmentDistance(vec2 point, ivec4 tile) { return distance; } -float softenedCoverage(float edge, float blurRadius) { - float aa = max(fwidth(edge), 0.0001); +float softenedCoverage(float edge, float blurRadius, float pixelSize) { + float aa = pixelSize; if (blurRadius <= 0.0001) return clamp(0.5 - edge / aa, 0.0, 1.0); float extent = max(blurRadius * 3.0, aa); return 1.0 - smoothstep(-extent, extent, edge); } -float strokeCoverage(vec2 point, ivec4 tile, float radius, float spreadRadius, float blurRadius) { +float strokeCoverage(vec2 point, ivec4 tile, float radius, float spreadRadius, float blurRadius, float pixelSize) { float distance = minimumSegmentDistance(point, tile); float edge = distance - radius - spreadRadius; - return softenedCoverage(edge, blurRadius); + return softenedCoverage(edge, blurRadius, pixelSize); } -float pathCoverage(ivec4 tile, ivec4 style) { +float pathCoverage(ivec4 tile, ivec4 style, vec2 pixelX, vec2 pixelY) { + float pixelSize = max(sqrt(0.5 * (dot(pixelX, pixelX) + dot(pixelY, pixelY))), 0.0001); float spreadRadius = intBitsToFloat(style.z); float blurRadius = intBitsToFloat(style.w); if (style.x == TILE_STYLE_STROKE) { - return strokeCoverage(localPosition, tile, intBitsToFloat(style.y), spreadRadius, blurRadius); + return strokeCoverage(localPosition, tile, intBitsToFloat(style.y), spreadRadius, blurRadius, pixelSize); } if ((tile.w & TILE_FULL_COVERAGE) != 0) return 1.0; if (blurRadius > 0.0001 || abs(spreadRadius) > 0.0001) { float distance = minimumSegmentDistance(localPosition, tile); float edge = windingAt(localPosition, tile.x, tile.y) == 0 ? distance : -distance; - return softenedCoverage(edge - spreadRadius, blurRadius); + return softenedCoverage(edge - spreadRadius, blurRadius, pixelSize); } - vec2 horizontal = dFdx(localPosition) * 0.25; - vec2 vertical = dFdy(localPosition) * 0.25; + vec2 horizontal = pixelX * 0.25; + vec2 vertical = pixelY * 0.25; float coverage = 0.0; coverage += windingAt(localPosition - horizontal - vertical, tile.x, tile.y) != 0 ? 0.25 : 0.0; coverage += windingAt(localPosition + horizontal - vertical, tile.x, tile.y) != 0 ? 0.25 : 0.0; @@ -142,8 +143,10 @@ void main() { int tileOffset = tileIndex * 2; ivec4 tile = texelFetch(TileBuffer, tileOffset); ivec4 style = texelFetch(TileBuffer, tileOffset + 1); + vec2 pixelX = dFdx(localPosition); + vec2 pixelY = dFdy(localPosition); if (style.x < 0) discard; - float coverage = pathCoverage(tile, style); + float coverage = pathCoverage(tile, style, pixelX, pixelY); if (coverage <= 0.0) discard; fragColor = samplePaint(tile.z); fragColor.a *= coverage; diff --git a/runtime/src/main/resources/assets/hollowengine/shaders/ui/rect_sdf.fsh b/runtime/src/main/resources/assets/hollowengine/shaders/ui/rect_sdf.fsh index 6c123757e..8684b9600 100644 --- a/runtime/src/main/resources/assets/hollowengine/shaders/ui/rect_sdf.fsh +++ b/runtime/src/main/resources/assets/hollowengine/shaders/ui/rect_sdf.fsh @@ -16,6 +16,7 @@ uniform sampler2D FontAtlas6; uniform sampler2D FontAtlas7; uniform float GlyphDistanceRange[MAX_GLYPH_PAGES]; uniform vec2 GlyphAtlasSize[MAX_GLYPH_PAGES]; +uniform float GlyphEdgeSoftness; in vec2 localPosition; in vec2 clipPosition; @@ -56,13 +57,43 @@ float roundedRectDistance(vec2 point, vec2 size, float radius) { return length(max(offset, 0.0)) + min(max(offset.x, offset.y), 0.0) - resolvedRadius; } -float sdfCoverage(float distance) { - float width = max(fwidth(distance), 0.0001); - return clamp(0.5 - distance / width, 0.0, 1.0); +float outlinePosition(vec2 point, vec2 size, float radius, float inset) { + const float QUARTER = 1.5707963; + vec2 halfSize = size * 0.5 - inset; + float r = clamp(radius - inset, 0.0, min(halfSize.x, halfSize.y)); + vec2 c = halfSize - r; + vec2 q = point - size * 0.5; + float top = 2.0 * c.x; + float side = 2.0 * c.y; + float arc = r * QUARTER; + if (q.x > c.x && q.y < -c.y) return top + r * (atan(q.y + c.y, q.x - c.x) + QUARTER); + if (q.x > c.x && q.y > c.y) return top + arc + side + r * atan(q.y - c.y, q.x - c.x); + if (q.x < -c.x && q.y > c.y) return 2.0 * top + 2.0 * arc + side + r * (atan(q.y - c.y, q.x + c.x) - QUARTER); + if (q.x < -c.x && q.y < -c.y) return 2.0 * top + 3.0 * arc + 2.0 * side + r * (atan(q.y + c.y, q.x + c.x) + 2.0 * QUARTER); + if (halfSize.y - abs(q.y) < halfSize.x - abs(q.x)) { + return q.y < 0.0 ? q.x + c.x : top + 2.0 * arc + side + (c.x - q.x); + } + return q.x > 0.0 ? top + arc + (q.y + c.y) : 2.0 * top + 3.0 * arc + side + (c.y - q.y); +} + +float localPixelSize() { + vec2 dx = dFdx(localPosition); + vec2 dy = dFdy(localPosition); + return max(sqrt(0.5 * (dot(dx, dx) + dot(dy, dy))), 0.0001); +} + +float dashCoverage(float position, float period, float dashLength, float pixelSize) { + float phase = mod(position, period); + float edge = min(phase, dashLength - phase); + return clamp(edge / min(pixelSize, 2.0) + 0.5, 0.0, 1.0); +} + +float sdfCoverage(float distance, float pixelSize) { + return clamp(0.5 - distance / pixelSize, 0.0, 1.0); } -float softenedCoverage(float edge, float blurRadius) { - float aa = max(fwidth(edge), 0.0001); +float softenedCoverage(float edge, float blurRadius, float pixelSize) { + float aa = pixelSize; if (blurRadius <= 0.0001) return clamp(0.5 - edge / aa, 0.0, 1.0); float extent = max(blurRadius * 2.0, aa); return 1.0 - smoothstep(-extent, extent, edge); @@ -125,6 +156,7 @@ void main() { int base = recordIndex * 4; vec4 geometry = texelFetch(RecordBuffer, base); vec2 glyphFootprint = fwidth(glyphUv); + float pixelSize = localPixelSize(); vec4 clip = texelFetch(RecordBuffer, base + 3); if (clipPosition.x < clip.x || clipPosition.y < clip.y || @@ -154,7 +186,7 @@ void main() { vec2 unitRange = vec2(GlyphDistanceRange[glyphPage]) / GlyphAtlasSize[glyphPage]; vec2 screenTexSize = vec2(1.0) / max(glyphFootprint, vec2(1e-8)); float pxRange = max(0.5 * dot(unitRange, screenTexSize), 2.0); - float edgeSoftness = 1.15; // 1.0 + MsdfSoftness (0.15) + float edgeSoftness = GlyphEdgeSoftness; float bias = min(geometry.y, max(0.5 - 0.5 * edgeSoftness / pxRange, 0.0)); float sdPx = median3(texel.r, texel.g, texel.b) - 0.5 + bias; coverage = clamp(sdPx * pxRange / edgeSoftness + 0.5, 0.0, 1.0); @@ -187,14 +219,14 @@ void main() { float distance = roundedRectDistance(localPosition, size, radius); if (effect.w > 0.5) { - float shadowCoverage = softenedCoverage(distance - effect.z, effect.y); + float shadowCoverage = softenedCoverage(distance - effect.z, effect.y, pixelSize); vec4 shadowColor = samplePaint(paintIndex); shadowColor.a *= shadowCoverage; if (shadowColor.a <= 0.0) discard; fragColor = shadowColor; return; } - float outerCoverage = sdfCoverage(distance); + float outerCoverage = sdfCoverage(distance, pixelSize); if (outerCoverage <= 0.0) discard; float borderCoverage = 0.0; @@ -202,8 +234,12 @@ void main() { vec2 innerSize = max(size - borderWidth * 2.0, vec2(0.0)); float innerRadius = max(radius - borderWidth, 0.0); float innerDistance = roundedRectDistance(localPosition - borderWidth, innerSize, innerRadius); - float innerCoverage = sdfCoverage(innerDistance); + float innerCoverage = sdfCoverage(innerDistance, pixelSize); borderCoverage = clamp(outerCoverage - innerCoverage, 0.0, 1.0); + if (effect.z > 0.0) { + float position = outlinePosition(localPosition, size, radius, borderWidth * 0.5); + borderCoverage *= dashCoverage(position, effect.z, -effect.w, pixelSize); + } } vec4 fillColor = samplePaint(paintIndex); diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/copy.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/copy.svg new file mode 100644 index 000000000..bf839b3bb --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/cut.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/cut.svg new file mode 100644 index 000000000..2cde61f50 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/cut.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/delete.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/delete.svg new file mode 100644 index 000000000..016dd966c --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/delete.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/new_file.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/new_file.svg new file mode 100644 index 000000000..010d4cc5b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/new_file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/new_folder.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/new_folder.svg new file mode 100644 index 000000000..a7138c32d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/new_folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/paste.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/paste.svg new file mode 100644 index 000000000..3df726476 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/paste.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/rename.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/rename.svg new file mode 100644 index 000000000..38374041a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/rename.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/reveal.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/reveal.svg new file mode 100644 index 000000000..5a341408a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/actions/reveal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/add.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/add.svg index 8d88eaf25..2199c68c1 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/add.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/add.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/box.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/box.svg index 9ea29550a..67e964cf2 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/box.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/box.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/code_editor.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/code_editor.svg index 7a6636fc1..f4bf09709 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/code_editor.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/code_editor.svg @@ -1,3 +1,6 @@ - - + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/console.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/console.svg index 1917040e0..7e279c408 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/console.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/console.svg @@ -1,10 +1,4 @@ - - - - - - - - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/copy.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/copy.svg index 915b1118f..bf839b3bb 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/copy.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/copy.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/cross.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/cross.svg index 473f2845c..6e1afe7d4 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/cross.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/cross.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/curve.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/curve.svg new file mode 100644 index 000000000..bc734213a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/curve.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/discord.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/discord.svg new file mode 100644 index 000000000..56e7447cf --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/discord.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/docs.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/docs.svg index bbfae43e1..56600cb7e 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/docs.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/docs.svg @@ -1,4 +1,7 @@ - - - - \ No newline at end of file + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/export.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/export.svg new file mode 100644 index 000000000..efb8681a9 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/export.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/animator.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/animator.svg new file mode 100644 index 000000000..dadcbf47a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/animator.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/archive.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/archive.svg new file mode 100644 index 000000000..a499867df --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/archive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/blocks.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/blocks.svg new file mode 100644 index 000000000..b7799e08f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/blocks.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/data.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/data.svg new file mode 100644 index 000000000..35d096bf2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/data.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/effect.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/effect.svg new file mode 100644 index 000000000..df706f742 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/effect.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/file.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/file.svg new file mode 100644 index 000000000..33a231e00 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/file.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/image.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/image.svg new file mode 100644 index 000000000..f5af0d097 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/model.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/model.svg new file mode 100644 index 000000000..656b226ca --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/model.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/rig.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/rig.svg new file mode 100644 index 000000000..4c6eadbc6 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/rig.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script.svg new file mode 100644 index 000000000..fb2d955c4 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_mixin.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_mixin.svg new file mode 100644 index 000000000..a5df6571f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_mixin.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_node.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_node.svg new file mode 100644 index 000000000..300d0b713 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_node.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_reload.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_reload.svg new file mode 100644 index 000000000..1ce5a6f0b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_reload.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_startup.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_startup.svg new file mode 100644 index 000000000..026eba3f3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_startup.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_ui.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_ui.svg new file mode 100644 index 000000000..6f4b682d4 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/script_ui.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/shader.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/shader.svg new file mode 100644 index 000000000..50d332ce2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/shader.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/sound.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/sound.svg new file mode 100644 index 000000000..4c815ec42 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/sound.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/story.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/story.svg new file mode 100644 index 000000000..933f23666 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/story.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/style.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/style.svg new file mode 100644 index 000000000..d4767dc4c --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/style.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/text.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/text.svg new file mode 100644 index 000000000..7efc63616 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/text.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/video.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/video.svg new file mode 100644 index 000000000..6d1770b43 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/files/video.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/film.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/film.svg index 07a4f57ff..026809c24 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/film.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/film.svg @@ -1,14 +1,7 @@ - - - - \ No newline at end of file + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/general.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/general.svg index 004eb33fa..468de83f2 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/general.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/general.svg @@ -1,4 +1,4 @@ - - - \ No newline at end of file + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/github.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/github.svg index 8507da072..c645d6b28 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/github.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/github.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/graph.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/graph.svg index 2f0f3effd..3a94b0bb0 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/graph.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/graph.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/info.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/info.svg new file mode 100644 index 000000000..7aa5741b0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/invisible.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/invisible.svg index 5f2cbdd86..42b70e704 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/invisible.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/invisible.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyboard.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyboard.svg index 56922ab0f..b3f3185ad 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyboard.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyboard.svg @@ -1,6 +1,8 @@ - - - - + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyframe.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyframe.svg new file mode 100644 index 000000000..51589cca7 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/keyframe.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/layers.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/layers.svg index 3b1ec1ae7..1b94a1592 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/layers.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/layers.svg @@ -1,12 +1,5 @@ - - - - - - - - - - - + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/link.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/link.svg index 0c830554f..1ffa8fbaf 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/link.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/link.svg @@ -1,15 +1,5 @@ - - - - + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/load.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/load.svg index 740ef0c36..d892493e4 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/load.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/load.svg @@ -1,22 +1,8 @@ - - - - - - + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/maximize.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/maximize.svg index 2db8e3021..e14b6b671 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/maximize.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/maximize.svg @@ -1,3 +1,16 @@ - - - + + + + + + \ No newline at end of file diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/minimize.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/minimize.svg index e503b6d8b..839866849 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/minimize.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/minimize.svg @@ -1,3 +1,16 @@ - - + + + + + \ No newline at end of file diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/options.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/options.svg index 1a7e3206e..468de83f2 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/options.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/options.svg @@ -1,20 +1,4 @@ - - - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/paste.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/paste.svg index 92e423160..3df726476 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/paste.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/paste.svg @@ -1,5 +1,4 @@ - - - - \ No newline at end of file + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/pause.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/pause.svg index 3264da956..08d825caa 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/pause.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/pause.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/play.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/play.svg index ae4f11b68..4ff0675aa 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/play.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/play.svg @@ -1,3 +1,3 @@ - - + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/range.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/range.svg new file mode 100644 index 000000000..f8c93a426 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/range.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/reload.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/reload.svg index f38a143e3..484d79769 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/reload.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/reload.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/remove.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/remove.svg index 4f1ee12a7..e1751264b 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/remove.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/remove.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/rename.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/rename.svg index 71672ae4d..38374041a 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/rename.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/rename.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save.svg index b4578897e..246160f77 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save.svg @@ -1,16 +1,5 @@ - - - - - + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save_all.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save_all.svg new file mode 100644 index 000000000..6a1c0a3be --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/save_all.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/search.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/search.svg index fac00c367..72284370e 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/search.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/search.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/stop.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/stop.svg new file mode 100644 index 000000000..a23e815e9 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/telegram.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/telegram.svg new file mode 100644 index 000000000..5c41dd459 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/telegram.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/capture.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/capture.svg new file mode 100644 index 000000000..e2180249d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/capture.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/close.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/close.svg new file mode 100644 index 000000000..6e1afe7d4 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/copy.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/copy.svg new file mode 100644 index 000000000..bf839b3bb --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/curves.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/curves.svg new file mode 100644 index 000000000..3a94b0bb0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/curves.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/cut.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/cut.svg new file mode 100644 index 000000000..2cde61f50 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/cut.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/delete.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/delete.svg new file mode 100644 index 000000000..016dd966c --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/delete.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/dope_sheet.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/dope_sheet.svg new file mode 100644 index 000000000..6b535535b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/dope_sheet.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/eye.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/eye.svg new file mode 100644 index 000000000..2e70c4cd9 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/eye_off.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/eye_off.svg new file mode 100644 index 000000000..42b70e704 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/eye_off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/film.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/film.svg new file mode 100644 index 000000000..026809c24 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/film.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/follow.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/follow.svg new file mode 100644 index 000000000..1d536a39b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/follow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/frame.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/frame.svg new file mode 100644 index 000000000..2dbc87635 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/frame.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/key_add.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/key_add.svg new file mode 100644 index 000000000..ac466af37 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/key_add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/load.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/load.svg new file mode 100644 index 000000000..559a80df2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/load.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/lock.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/lock.svg new file mode 100644 index 000000000..02fbecf3a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/lock_open.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/lock_open.svg new file mode 100644 index 000000000..a11d2788e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/lock_open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/loop.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/loop.svg new file mode 100644 index 000000000..337a4319b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/loop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/more.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/more.svg new file mode 100644 index 000000000..6c120ac1d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/next_key.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/next_key.svg new file mode 100644 index 000000000..b447cffd3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/next_key.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/paste.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/paste.svg new file mode 100644 index 000000000..3df726476 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/paste.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/pause.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/pause.svg new file mode 100644 index 000000000..08d825caa --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/play.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/play.svg new file mode 100644 index 000000000..4ff0675aa --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/preset.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/preset.svg new file mode 100644 index 000000000..1fd44b3ba --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/preset.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/prev_key.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/prev_key.svg new file mode 100644 index 000000000..822ef8a1f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/prev_key.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/save.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/save.svg new file mode 100644 index 000000000..246160f77 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/save.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/smooth.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/smooth.svg new file mode 100644 index 000000000..4281883c3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/smooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/snap.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/snap.svg new file mode 100644 index 000000000..f5d5ca4f0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/snap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/to_end.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/to_end.svg new file mode 100644 index 000000000..e708a744d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/to_end.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/to_start.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/to_start.svg new file mode 100644 index 000000000..95e377d7c --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/to_start.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/zoom_in.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/zoom_in.svg new file mode 100644 index 000000000..446ec4189 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/zoom_in.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/zoom_out.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/zoom_out.svg new file mode 100644 index 000000000..8b81c936d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/timeline/zoom_out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/add.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/add.svg new file mode 100644 index 000000000..2199c68c1 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/beam.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/beam.svg new file mode 100644 index 000000000..d28be14f9 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/beam.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/collision.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/collision.svg new file mode 100644 index 000000000..c3324efbc --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/collision.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_channels.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_channels.svg new file mode 100644 index 000000000..5ae96b710 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_channels.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_gradient.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_gradient.svg new file mode 100644 index 000000000..0bbdfae9a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_gradient.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_solid.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_solid.svg new file mode 100644 index 000000000..401215ad5 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/color_solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/depth.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/depth.svg new file mode 100644 index 000000000..1b94a1592 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/depth.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/duplicate.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/duplicate.svg new file mode 100644 index 000000000..bf839b3bb --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/duplicate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/emission.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/emission.svg new file mode 100644 index 000000000..f92db1cb6 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/emission.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/facing.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/facing.svg new file mode 100644 index 000000000..2e70c4cd9 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/facing.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/flipbook.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/flipbook.svg new file mode 100644 index 000000000..6d0fba4e2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/flipbook.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/floor.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/floor.svg new file mode 100644 index 000000000..3aa40daeb --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/floor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/force.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/force.svg new file mode 100644 index 000000000..3813e7659 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/force.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/handles.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/handles.svg new file mode 100644 index 000000000..7edf1b25a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/handles.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/info.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/info.svg new file mode 100644 index 000000000..7aa5741b0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/key.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/key.svg new file mode 100644 index 000000000..9ff49e2b0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/key.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/material.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/material.svg new file mode 100644 index 000000000..2301694b0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/material.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/mesh.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/mesh.svg new file mode 100644 index 000000000..67e964cf2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/mesh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/model.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/model.svg new file mode 100644 index 000000000..5d07e2c5d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/model.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/modules.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/modules.svg new file mode 100644 index 000000000..a29ddc64e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/modules.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/more.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/more.svg new file mode 100644 index 000000000..82b3c1499 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/motion.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/motion.svg new file mode 100644 index 000000000..f87c70acd --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/motion.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_beam.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_beam.svg new file mode 100644 index 000000000..b6a928ccb --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_beam.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_camera_shake.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_camera_shake.svg new file mode 100644 index 000000000..4e96c6b22 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_camera_shake.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_cube.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_cube.svg new file mode 100644 index 000000000..799802b46 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_cube.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_cylinder.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_cylinder.svg new file mode 100644 index 000000000..57c8485fc --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_cylinder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_effect.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_effect.svg new file mode 100644 index 000000000..4be1fc6a2 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_effect.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_emitter.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_emitter.svg new file mode 100644 index 000000000..81861cf96 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_emitter.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_group.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_group.svg new file mode 100644 index 000000000..dd08e3b1c --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_group.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_model.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_model.svg new file mode 100644 index 000000000..c2a6e44dd --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_model.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_plane.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_plane.svg new file mode 100644 index 000000000..92779cc85 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_plane.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_post_effect.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_post_effect.svg new file mode 100644 index 000000000..9af3d4fb3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_post_effect.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_sky.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_sky.svg new file mode 100644 index 000000000..a932d9523 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_sky.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_sphere.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_sphere.svg new file mode 100644 index 000000000..efa95b349 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_sphere.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_trail.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_trail.svg new file mode 100644 index 000000000..daffc7b67 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/node_trail.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/noise.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/noise.svg new file mode 100644 index 000000000..32e9b80e3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/noise.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/particle.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/particle.svg new file mode 100644 index 000000000..0e37bbb97 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/particle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/post.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/post.svg new file mode 100644 index 000000000..20bd042b0 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/post.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/remove.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/remove.svg new file mode 100644 index 000000000..e1751264b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/remove.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/rename.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/rename.svg new file mode 100644 index 000000000..38374041a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/rename.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/restart.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/restart.svg new file mode 100644 index 000000000..484d79769 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/restart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shader.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shader.svg new file mode 100644 index 000000000..dc73e878e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shader.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shake.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shake.svg new file mode 100644 index 000000000..8206b2a0d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shake.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shape.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shape.svg new file mode 100644 index 000000000..c577fa9aa --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/shape.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/sky.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/sky.svg new file mode 100644 index 000000000..3a69b713b --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/sky.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/space.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/space.svg new file mode 100644 index 000000000..8a398cf11 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/space.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/spawn.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/spawn.svg new file mode 100644 index 000000000..d578d5b53 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/spawn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/stats.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/stats.svg new file mode 100644 index 000000000..29343f536 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/stats.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/sub_emitter.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/sub_emitter.svg new file mode 100644 index 000000000..598d22285 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/sub_emitter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/timeline.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/timeline.svg new file mode 100644 index 000000000..23d25354a --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/timeline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/toggle.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/toggle.svg new file mode 100644 index 000000000..120b9f6a8 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/toggle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/trail.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/trail.svg new file mode 100644 index 000000000..66b9473e8 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/trail.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/transform.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/transform.svg new file mode 100644 index 000000000..b53e1c536 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/transform.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/uniforms.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/uniforms.svg new file mode 100644 index 000000000..21520986e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/uniforms.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_const.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_const.svg new file mode 100644 index 000000000..e5eb9c0f3 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_const.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_curve.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_curve.svg new file mode 100644 index 000000000..a92c6d346 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_curve.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_expr.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_expr.svg new file mode 100644 index 000000000..d920db051 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_expr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_range.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_range.svg new file mode 100644 index 000000000..a98a10b00 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/value_range.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/velocity.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/velocity.svg new file mode 100644 index 000000000..9e7701c19 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/vfx/velocity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/visible.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/visible.svg index 9cc0ce543..2e70c4cd9 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/visible.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/visible.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/volume.svg b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/volume.svg index b535d2a5f..bb4a4e37b 100644 --- a/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/volume.svg +++ b/runtime/src/main/resources/assets/hollowengine/textures/gui/icons/volume.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/add_trade_icon.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/add_trade_icon.png deleted file mode 100644 index 0882e36b3..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/add_trade_icon.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/counter.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/counter.png deleted file mode 100644 index 688d54c1b..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/counter.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/entity_overlay.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/entity_overlay.png deleted file mode 100644 index ac89304b9..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/entity_overlay.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/equal_icon.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/equal_icon.png deleted file mode 100644 index 6f1e739e2..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/equal_icon.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/left_button.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/left_button.png deleted file mode 100644 index ba610dbad..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/left_button.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/plus_icon.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/plus_icon.png deleted file mode 100644 index c8f7b1f83..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/plus_icon.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/right_button.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/right_button.png deleted file mode 100644 index 2e758b946..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/right_button.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_entry.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_entry.png deleted file mode 100644 index 960e297aa..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_entry.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_icon.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_icon.png deleted file mode 100644 index fc552921c..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_icon.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_menu.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_menu.png deleted file mode 100644 index 4ea8718fe..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_menu.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_slot_1.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_slot_1.png deleted file mode 100644 index 506d77f8d..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_slot_1.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_slot_2.png b/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_slot_2.png deleted file mode 100644 index 54ac462fd..000000000 Binary files a/runtime/src/main/resources/assets/hollowengine/textures/gui/trades/trade_slot_2.png and /dev/null differ diff --git a/runtime/src/main/resources/assets/hollowengine/textures/particle/white.png b/runtime/src/main/resources/assets/hollowengine/textures/particle/white.png new file mode 100644 index 000000000..b54e0c463 Binary files /dev/null and b/runtime/src/main/resources/assets/hollowengine/textures/particle/white.png differ 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 fde07acda..1eba549be 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 @@ -1,163 +1,130 @@ +@import "hollowengine:ui/styles/theme.hss"; + .animator-button { size: 100% 22px; align-items: center; justify-content: center; padding: 4px 6px; - background: #2E323C; - border: 1px #FFFFFF1A; - border-radius: 3px; + background: $bg-selected; + border: 1px alpha($white, 0.1); + border-radius: $radius-small; cursor: hand; transition: background 250ms ease-out, border-color 250ms ease-out; } -.animator-button:hover { - background: #3A404C; - border-color: #6B7385; -} - -.animator-button-label { - font-size: 9px; - foreground: #DCE0EB; - text-wrap: false; - text-overflow: dots; -} - .animator-icon-button { padding: 3px; - background: #00000000; - border-radius: 3px; + background: transparent; + border-radius: $radius-small; + tint: $text-soft; cursor: hand; - transition: background 250ms ease-out; + transition: background 110ms ease-out, tint 110ms ease-out; } .animator-icon-button:hover { - background: #FFFFFF14; + background: $bg-hover; + tint: $white; } .animator-icon-button.active { - background: #D77F1C33; - border: 1px #D77F1C; - tint: #E9A44A; + background: $bg-selected; + tint: $accent; } .animator-icon-button.active:hover { - background: #D77F1C4D; + background: $bg-selected; +} + +.viewport-toolbar { + padding: 2px; + gap: 1px; + background: alpha($bg-window, 0.86); + border-radius: $radius; } .animator-pill { padding: 5px 3px; - background: #2E323C; - border: 1px #FFFFFF1A; - border-radius: 3px; + background: $bg-selected; + border: 1px alpha($white, 0.1); + border-radius: $radius-small; cursor: hand; transition: background 250ms ease-out, border-color 250ms ease-out; } .animator-pill:hover { - background: #3A404C; - border-color: #DCE0EB; + background: $bg-pressed; + border-color: $text-strong; } .animator-pill.active { - background: #6199EB; - border-color: #FFFFFF1A; + background: $accent-soft; + border-color: alpha($white, 0.1); } .animator-pill-label { font-size: 8px; - foreground: #DCE0EB; + foreground: $text-strong; text-wrap: false; } -.animator-layer-row { - size: 100% 30px; - align-items: center; - gap: 4px; - padding: 4px; - background: #21242B; - border: 1px #FFFFFF1A; - border-radius: 3px; - cursor: hand; - transition: background 250ms ease-out, border-color 250ms ease-out; -} - -.animator-layer-row:hover { - background: #2E323C; -} - -.animator-layer-row.open { - background: #2E323C; -} - -.animator-layer-row.selected { - border-color: #EB9433; -} - -.animator-delete { - size: 18px 18px; - align-items: center; +/* A state of a controller graph; the frame is the graph node's. */ +.animator-state { + padding: 6px 8px; + gap: 5px; justify-content: center; - border-radius: 3px; - background: #00000000; - cursor: hand; - transition: background 250ms ease-out; } -.animator-delete:hover { - background: #D95757; +.animator-state.entry { + border-color: $success; } -.animator-delete-label { - font-size: 11px; - foreground: #D95757; - transition: foreground 250ms ease-out; +.animator-state.any-state { + border-color: #9E85EB; } -.animator-delete:hover .animator-delete-label { - foreground: #DCE0EB; +/* After the kinds: a selected entry state reads as selected. */ +.animator-state.selected { + border-color: $accent; } -.animator-splitter { - size: 4px 100%; - background: #FFFFFF1A; - cursor: resize-horizontal; - transition: background 250ms ease-out; +.animator-state-row { + gap: 5px; } -.animator-splitter:hover { - background: #6199EB; +.animator-state-icon { + size: 10px 10px; + tint: $text-soft; } -.animator-node { - border-radius: 5px; - cursor: hand; - transition: border-color 250ms ease-out; +.animator-state.any-state .animator-state-icon { + tint: #9E85EB; } -.animator-node:hover { - border-color: #C4CBD9; +.animator-state.any-state .graph-node-title { + foreground: #9E85EB; } -.animator-minimap-mark { - border-radius: 2px; - cursor: hand; - transition: background 250ms ease-out; +.animator-play-mode { + padding: 1px 4px; + font-size: 7px; + foreground: $text-soft; + background: $bg-field; + border: 1px $border; + border-radius: $radius-small; + text-wrap: false; } -.animator-edge-label { - padding: 3px; - background: #21242B; - border: 1px #FFFFFF1A; - border-radius: 3px; - cursor: hand; - transition: background 250ms ease-out, border-color 250ms ease-out; +.animator-entry-badge { + size: 10px 10px; + tint: $success; } -.animator-edge-label:hover { - background: #2E323C; - border-color: #C4CBD9; +.animator-empty-title { + font-size: $font-size-title; + foreground: $text; } -.animator-edge-label.selected { - border-color: #EB9433; +.animator-empty-hint { + font-size: $font-size; + foreground: $text-muted; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/asset-manager.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/asset-manager.hss index 6a8a432f0..132da2090 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/asset-manager.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/asset-manager.hss @@ -1,8 +1,10 @@ +@import "hollowengine:ui/styles/theme.hss"; + .asset-manager-root { margin: 1px; padding: 0px; gap: 0px; - background: #1E2127; + background: $bg-field; clip: true; } @@ -10,7 +12,7 @@ min-width: 170px; gap: 3px; padding: 3px; - background: #24272E; + background: $bg-raised; } .asset-tabs { @@ -18,44 +20,44 @@ height: fit; gap: 1px; padding: 1px; - background: #1E2127; - border-radius: 4px; + background: $bg-field; + border-radius: $radius; } .asset-tab { align-items: center center; - border-radius: 3px; - foreground: #8f9bad; - background: #00000000; + border-radius: $radius-small; + foreground: $text-muted; + background: transparent; transition: all 120ms ease-out; } .asset-tab:hover { - background: #343943; + background: $bg-hover; } .asset-tab.selected { - foreground: #f0f6ff; - background: rgba(67, 106, 154, 0.62); + foreground: $caret; + background: $row-selected; } .asset-tab-label { height: fit; - font-size: 7px; + font-size: $font-size-small; wrap: false; } .asset-splitter { - background: #323846; + background: $bg-pressed; transition: background 100ms ease-out; } .asset-splitter:hover { - background: #5F6677; + background: $accent; } .asset-content { - background: #1E2127; + background: $bg-field; } .asset-toolbar { @@ -64,21 +66,21 @@ padding: 2px 5px; gap: 5px; align-items: start center; - background: #2A2E36; - border-bottom: 1px #323846; + background: $bg-hover; + border-bottom: 1px $bg-pressed; } .asset-breadcrumb { height: fit; - foreground: #d7deea; - font-size: 8px; + foreground: $text-strong; + font-size: $font-size; text-overflow: dots; } .asset-count { width: fit; - foreground: #687588; - font-size: 7px; + foreground: $text-faint; + font-size: $font-size-small; wrap: false; } @@ -86,18 +88,18 @@ min-width: 78px; min-height: 16px; padding: 1px 4px; - font-size: 7px; + font-size: $font-size-small; } .asset-refresh { size: 15px 15px; align-items: center center; - border-radius: 3px; + border-radius: $radius-small; transition: background 100ms ease-out, rotate 240ms ease-out; } .asset-refresh:hover { - background: #414653; + background: $bg-hover; } .asset-refresh.loading { @@ -106,16 +108,13 @@ .asset-refresh-icon { size: 9px 9px; - tint: #aeb8ca; + tint: $text-soft; } .asset-grid-scroll { width: 100%; padding: 6px; - scrollbar: 5px; - scrollbar-track: rgba(255, 255, 255, 0.03); - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + @apply $thin-scroll; } .asset-grid { @@ -131,34 +130,35 @@ padding: 3px; gap: 2px; align-items: center start; - border: 1px #00000000; - border-radius: 5px; - background: #00000000; + border: 1px transparent; + border-radius: $radius; + background: transparent; transition: all 100ms ease-out; } .asset-tile:hover { - background: #2A2E36; - border: 1px #3a4351; + background: $bg-hover; + border: 1px $border; } .asset-tile.selected { - background: rgba(67, 106, 154, 0.42); - border: 1px #5f83b2; + background: $row-selected; + border: 1px $accent; } .asset-preview { size: 62px 55px; align-items: center center; - background: #17191e; - border: 1px #323846; - border-radius: 4px; + background: $bg-window; + border: 1px $bg-pressed; + border-radius: $radius; clip: true; } +/* The folder carries its own color, the same as in the project tree. */ .asset-folder-icon { - size: 42px 42px; - tint: #f4b942; + size: 38px 38px; + tint: $white; } .asset-file-icon { @@ -167,16 +167,16 @@ .asset-tile-name { align: center start; - foreground: #d7deea; - font-size: 7px; + foreground: $text-strong; + font-size: $font-size-small; wrap: false; text-overflow: dots; } .asset-pack-name { align: center start; - foreground: #687588; - font-size: 6px; + foreground: $text-faint; + font-size: 7px; text-overflow: dots; } @@ -187,24 +187,24 @@ .asset-message-text { max-width: 280px; height: fit; - foreground: #7f8a9d; - font-size: 8px; + foreground: $text-muted; + font-size: $font-size; align: center center; } .asset-filter-icon, .asset-filter-close { size: 11px 11px; - tint: #aeb8ca; + tint: $text-soft; } .asset-filter-input { min-height: 17px; padding: 1px 2px; - font-size: 8px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + font-size: $font-size; + caret-color: $caret; + selection-color: $selection; } .asset-filter-close:hover { - tint: #ffffff; + tint: $white; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss index 720ccc9e6..b4f0cf405 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/dialogue.hss @@ -178,7 +178,7 @@ mask: linear-gradient(to bottom, transparent, white 26%, white 74%, transparent); } -/* Padded from the code, by half a viewport, so the first and last option can reach dead centre. */ +/* Padded from the code, by half a viewport, so the first and last option can reach dead center. */ #choice-list { size: fill fit; align-items: center start; @@ -255,7 +255,7 @@ .choice:hover .choice-text { color: #ffffff; } /* - * Multiplayer: after this player has voted the options they did not pick lose their colour and stop + * Multiplayer: after this player has voted the options they did not pick lose their color and stop * reacting, so it reads as "no longer yours to decide" rather than as a dead button. */ .choice:disabled { diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/docking.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/docking.hss index 0a009c0be..fcd9834ca 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/docking.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/docking.hss @@ -1,28 +1,41 @@ -.dock-stack, -.dock-window { - background: #1E1F22; -} +/* The dock: windows, tab bars, splitters and side stripes. Colors come from theme.hss. */ + +@import "hollowengine:ui/styles/theme.hss"; + +.dock-stack, +.dock-window, +.dock-content, .dock-splitter { - background: #1E1F22; + background: $bg-window; } .dock-tab { - background: #1E1F22; - border-radius: 4px; + background: transparent; + foreground: $text-muted; + border-radius: $radius; align: start center; transition: background 120ms ease-out, foreground 120ms ease-out, - translate 140ms ease-out; - animations: dock-tab-in 150ms ease-out; + translate 110ms cubic-bezier(0.2, 0.8, 0.2, 1); + animations: dock-tab-in 120ms cubic-bezier(0.2, 0.8, 0.2, 1); } @keyframes dock-tab-in { - from { opacity: 0; scale: 0.94; } + from { opacity: 0; scale: 0.92; } to { opacity: 1; scale: 1; } } +.dock-tab-indicator { + background: transparent; + transition: background 120ms ease-out; +} + +.dock-tab.selected .dock-tab-indicator { + background: $accent; +} + .dock-tab-icon { size: 12px 12px; margin-right: 5px; @@ -30,102 +43,187 @@ .dock-tab-bar { scrollbar-overlay: true; - scrollbar-thickness: 3px; + scrollbar-side: start; + scrollbar-thickness: 2px; scrollbar-margin: 0px; scrollbar-track: none; - scrollbar-thumb: rgba(155, 169, 194, 0.48); - scrollbar-thumb-radius: 2px; -} - -.dock-tab-bar.compact { - background: none; + scrollbar-thumb: $scroll-thumb; + scrollbar-thumb-hover: alpha($text-soft, 0.6); + scrollbar-hover-thickness: 3px; + scrollbar-thumb-radius: 1px; } .dock-tab:hover { - background: #31343D; + background: $bg-panel; + foreground: $text-strong; } .dock-tab.selected { - background: #24272E; + background: $bg-panel; + foreground: $caret; } .dock-tab:dragging { transition: none; } -.dock-content { - background: #1E1F22; -} - .dock-close-button { - margin: 3px; - padding: 0px; - border-radius: 3px; - foreground: #8d96a8; - transition: - foreground 100ms ease-out, - background 100ms ease-out; + margin: 0px 4px 0px 2px; + border-radius: $radius-small; + background: transparent; + transition: background 100ms ease-out; } .dock-close-button:hover { - background: rgba(255, 255, 255, 0.10); - foreground: #ffffff; + background: $bg-hover; +} + +.dock-close-icon { + size: 8px 8px; + tint: $text-faint; + transition: tint 100ms ease-out; +} + +.dock-tab:hover .dock-close-icon, +.dock-tab.selected .dock-close-icon { + tint: $text-soft; +} + +.dock-close-button:hover .dock-close-icon { + tint: $white; } .dock-stripe { - background: #191A1D; + background: $bg-window; padding: 0px 0px; gap: 2px; - transition: size 160ms ease-out; + transition: size 120ms ease-out; } .dock-stripe-button { - border-radius: 4px; + border-radius: $radius; scale: 0.75; - opacity: 0.66; + opacity: 0.6; transition: background 120ms ease-out, - opacity 120ms ease-out; + opacity 120ms ease-out, + translate 110ms cubic-bezier(0.2, 0.8, 0.2, 1); } .dock-stripe-button:hover { - background: #31343D; + background: $bg-hover; scale: 0.95; opacity: 1; } +.dock-stripe-button.dragging { + background: $bg-selected; + scale: 1; + opacity: 1; +} + .dock-stripe-button.selected { - background: #3A3F4B; + background: $bg-selected; opacity: 1; } .dock-stripe-icon { - size: 14px 14px; + size: 12px 12px; } .dock-pinned-panel { - background: #1E1F22; + background: $bg-window; +} + +.dock-pinned-island { + size: fill fill; + margin: 2px; + background: $bg-panel; + border-radius: $radius-large; + clip: true; +} + +.dock-pinned-island .dock-content { + background: none; } .dock-pinned-panel.sliding, .dock-splitter.sliding { - transition: size 160ms ease-out; + transition: size 120ms ease-out; } .dock-pinned-header { - background: #1E1F22; - padding: 0px 4px 0px 6px; - gap: 5px; - border-bottom: 1px #2B2D31; + padding: 0px 4px 0px $panel-inset; + gap: 6px; } .dock-pinned-header-icon { size: 12px 12px; + tint: $text-soft; } .dock-pinned-header-label { - font-size: 10px; - foreground: #c8cfdb; + foreground: $text-strong; +} + +.dock-stripe-divider { + margin: 3px 0px; + background: $bg-hover; +} + + +.dock-drop-zone { + padding: 3px; + background: alpha($accent, 0.1); + border: 1px alpha($accent, 0.32); + border-radius: $radius; + transition: + background 100ms ease-out, + border 100ms ease-out, + scale 100ms cubic-bezier(0.2, 0.8, 0.2, 1); + animations: dock-drop-zone-in 110ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.dock-drop-zone.active { + background: alpha($accent, 0.34); + border: 1px alpha($accent, 0.95); + scale: 1.08; +} + +@keyframes dock-drop-zone-in { + from { opacity: 0; scale: 0.85; } + to { opacity: 1; scale: 1; } +} + +.dock-drop-zone-glyph { + background: alpha($accent, 0.35); + border-radius: 2px; + transition: background 100ms ease-out; +} + +.dock-drop-zone.active .dock-drop-zone-glyph { + background: alpha($caret, 0.85); +} + +.dock-drop-preview { + background: alpha($accent, 0.14); + border: 1px alpha($accent, 0.8); + border-radius: $radius-large; + opacity: 0; + transition: + translate 110ms cubic-bezier(0.2, 0.8, 0.2, 1), + size 110ms cubic-bezier(0.2, 0.8, 0.2, 1), + opacity 120ms ease-out; + animations: dock-drop-preview-in 120ms ease-out; +} + +.dock-drop-preview.visible { + opacity: 1; +} + +@keyframes dock-drop-preview-in { + from { opacity: 0; } + to { opacity: 1; } } .dock-content-body { diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/entity-editor.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/entity-editor.hss index 31ec66631..f9462c6ba 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/entity-editor.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/entity-editor.hss @@ -1,8 +1,10 @@ +@import "hollowengine:ui/styles/theme.hss"; + .ee-root { size: fill fill; gap: 6px; padding: 6px; - background: rgba(16, 17, 20, 0.94); + background: alpha($bg-window, 0.94); } /* ---- preview ---- */ @@ -11,9 +13,9 @@ grow: 1; size: 0px 100%; min-size: 0px 0px; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; clip: true; } @@ -26,15 +28,15 @@ max-size: 70% fit; layer: 1000; align-items: center center; - background: rgba(30, 32, 38, 0.9); - border-radius: 6px; - border: 1px #31343D; + background: alpha($bg-field, 0.9); + border-radius: $radius-large; + border: 1px $bg-selected; backdrop-filter: blur(9px); } .ee-title { size: fit fit; - foreground: #E4E9F4; + foreground: $text-strong; font-size: 12px; wrap: false; text-overflow: dots; @@ -42,7 +44,7 @@ .ee-subtitle { size: fit fit; - foreground: #8B93A5; + foreground: $text-muted; font-size: 9px; wrap: false; text-overflow: dots; @@ -59,28 +61,28 @@ .ee-chip { size: 26px 26px; align-items: center center; - background: rgba(36, 39, 46, 0.82); - border-radius: 7px; - border: 1px #31343D; + background: alpha($bg-raised, 0.82); + border-radius: $radius-large; + border: 1px $bg-selected; hoverable: true; clickable: true; transition: background 140ms ease-out, border 140ms ease-out; backdrop-filter: blur(9px); } -.ee-chip:hover { background: rgba(49, 52, 61, 0.88); } +.ee-chip:hover { background: $bg-hover; } .ee-chip.selected { - background: rgba(215, 127, 28, 0.72); - border: 1px #D77F1C; + background: $row-selected; + border: 1px $accent; } .ee-chip-icon { size: 15px 15px; - tint: #C4CBDA; + tint: $text; } -.ee-chip.selected .ee-chip-icon { tint: #1E1F22; } +.ee-chip.selected .ee-chip-icon { tint: $bg-panel; } .ee-stats { align: end start; @@ -90,9 +92,9 @@ size: fit fit; min-size: 150px 0px; layer: 1000; - background: rgba(36, 39, 46, 0.86); - border-radius: 7px; - border: 1px #31343D; + background: alpha($bg-raised, 0.86); + border-radius: $radius-large; + border: 1px $bg-selected; backdrop-filter: blur(9px); } @@ -104,14 +106,14 @@ .ee-stat-label { size: fit fit; - foreground: #8B93A5; + foreground: $text-muted; font-size: 9px; wrap: false; } .ee-stat-value { size: fit fit; - foreground: #D77F1C; + foreground: $accent; font-size: 9px; wrap: false; } @@ -120,14 +122,14 @@ .ee-splitter { size: 5px 100%; - background: rgba(95, 102, 119, 0.0); - border-radius: 3px; + background: transparent; + border-radius: $radius-small; hoverable: true; transition: background 140ms ease-out; } -.ee-splitter:hover { background: rgba(215, 127, 28, 0.55); } -.ee-splitter:dragging { background: #D77F1C; } +.ee-splitter:hover { background: $accent; } +.ee-splitter:dragging { background: $accent; } /* ---- sidebar ---- */ @@ -135,9 +137,9 @@ min-size: 240px 0px; gap: 6px; padding: 8px; - background: #24272E; - border-radius: 6px; - border: 1px #31343D; + background: $bg-raised; + border-radius: $radius-large; + border: 1px $bg-selected; clip: true; } @@ -150,7 +152,7 @@ .ee-header-title { size: fit fit; - foreground: #C4CBDA; + foreground: $text; font-size: 11px; wrap: false; text-overflow: dots; @@ -166,29 +168,29 @@ min-height: 24px; padding: 4px 8px; align-items: center center; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; hoverable: true; clickable: true; transition: background 140ms ease-out, border 140ms ease-out; } -.ee-tab:hover { background: #2A2D35; } +.ee-tab:hover { background: $bg-hover; } .ee-tab.active { - background: rgba(215, 127, 28, 0.18); - border: 1px #D77F1C; + background: $row-selected; + border: 1px $accent; } .ee-tab-label { size: fit fit; - foreground: #C4CBDA; + foreground: $text; font-size: 9px; wrap: false; } -.ee-tab.active .ee-tab-label { foreground: #E8912E; } +.ee-tab.active .ee-tab-label { foreground: $accent; } .ee-search { size: 100% fit; @@ -196,14 +198,14 @@ gap: 6px; padding: 2px 6px; align-items: start center; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; } .ee-search-icon { size: 12px 12px; - tint: #8B93A5; + tint: $text-muted; } .ee-sidebar-body { @@ -217,12 +219,12 @@ size: 100% fit; gap: 5px; padding: 7px; - background: #1E1F22; - border-radius: 7px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; } -.ee-card.virtual { border: 1px #3B4557; } +.ee-card.virtual { border: 1px $bg-selected; } .ee-card-head { size: 100% fit; @@ -235,12 +237,12 @@ .ee-card-icon { size: 13px 13px; - tint: #D77F1C; + tint: $accent; } .ee-card-title { size: fit fit; - foreground: #C4CBDA; + foreground: $text; font-size: 10px; wrap: false; text-overflow: dots; @@ -248,15 +250,15 @@ .ee-badge { size: fit fit; - foreground: #7C8AA5; + foreground: $text-muted; font-size: 8px; padding: 1px 5px; - background: rgba(59, 69, 87, 0.5); - border-radius: 4px; + background: alpha($bg-selected, 0.5); + border-radius: $radius; wrap: false; } -.ee-card-remove { tint: #C0564B; } +.ee-card-remove { tint: $danger; } .ee-card-body { size: 100% fit; @@ -274,9 +276,9 @@ size: 100% fit; gap: 5px; padding: 6px; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; } .ee-add-list { @@ -289,22 +291,22 @@ gap: 6px; padding: 4px 5px; align-items: start center; - border-radius: 4px; + border-radius: $radius; hoverable: true; clickable: true; transition: background 140ms ease-out; } -.ee-add-row:hover { background: #2A2D35; } +.ee-add-row:hover { background: $bg-hover; } .ee-add-icon { size: 13px 13px; - tint: #8B93A5; + tint: $text-muted; } .ee-add-label { size: fit fit; - foreground: #C4CBDA; + foreground: $text; font-size: 9px; wrap: false; text-overflow: dots; @@ -312,7 +314,7 @@ .ee-add-id { size: fit fit; - foreground: #5F6677; + foreground: $border-strong; font-size: 8px; wrap: false; text-overflow: dots; @@ -322,19 +324,19 @@ .ee-section-title { size: 100% fit; - foreground: #D77F1C; + foreground: $accent; font-size: 10px; margin: 4px 0px 0px 0px; } .ee-script-icon { size: 14px 14px; - tint: #8B93A5; + tint: $text-muted; } .ee-script-name { size: fit fit; - foreground: #C4CBDA; + foreground: $text; font-size: 9px; wrap: false; text-overflow: dots; @@ -342,7 +344,7 @@ .ee-script-path { size: fit fit; - foreground: #5F6677; + foreground: $border-strong; font-size: 8px; wrap: false; text-overflow: dots; @@ -353,10 +355,10 @@ .ee-dialog { gap: 8px; padding: 12px; - background: #24272E; - border-radius: 8px; - border: 1px #5F6677; - shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.5); + background: $bg-raised; + border-radius: $radius-large; + border: 1px $border-strong; + shadow: 2px 2px 10px 0px $shadow; } .ee-dialog-head { @@ -367,7 +369,7 @@ .ee-dialog-title { size: fit fit; - foreground: #E4E9F4; + foreground: $text-strong; font-size: 11px; wrap: false; text-overflow: dots; @@ -375,9 +377,9 @@ .ee-asset-tree { width: 100%; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; padding: 4px; } @@ -394,15 +396,15 @@ } .slot { - background: #16181C; - border-radius: 3px; - border: 1px #31343D; + background: $bg-window; + border-radius: $radius-small; + border: 1px $bg-selected; transition: background 140ms ease-out, border 140ms ease-out; } .slot:hover { - background: #2A2D35; - border: 1px #D77F1C; + background: $bg-hover; + border: 1px $accent; } .slot-carried { @@ -411,5 +413,5 @@ .ee-busy { size: 14px 14px; - tint: #D77F1C; + tint: $accent; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss new file mode 100644 index 000000000..eecfed209 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/graph.hss @@ -0,0 +1,85 @@ +@import "hollowengine:ui/styles/theme.hss"; + +/* A node graph: the canvas, its nodes and the minimap. Editors add tags to nodes for their own kinds. */ + +.graph-canvas { + background: $bg-window; + font-family: $font; + font-size: $font-size; +} + +.graph-node { + background: linear-gradient(180deg, $bg-hover, $bg-panel); + border: 1px $border; + border-radius: $radius; + shadow: 0px 2px 9px 0px $shadow; + cursor: hand; + transition: border-color 250ms ease-out; +} + +.graph-node:hover { + border-color: $border-strong; +} + +.graph-node.selected { + border-color: $accent; + shadow: 0px 0px 5px 0px alpha($accent, 0.22); +} + +.graph-node-title { + font-size: $font-size; + foreground: $text-strong; + text-wrap: false; + text-overflow: dots; +} + +.graph-node-subtitle { + font-size: $font-size-small; + foreground: $text-muted; + text-wrap: false; + text-overflow: dots; +} + +.graph-minimap { + background: alpha($bg-panel, 0.92); + border: 1px $border; + border-radius: $radius; +} + +.graph-minimap-mark { + border: 1px alpha($text-strong, 0); + border-radius: 2px; + cursor: hand; + transition: border-color 150ms ease-out; +} + +.graph-minimap-mark:hover { + border-color: $text-strong; +} + +.graph-marquee { + background: alpha($accent, 0.08); + border: 1px alpha($accent, 0.7); + border-radius: $radius-small; +} + +.graph-link-label { + padding: 2px 4px; + font-size: $font-size-small; + foreground: $text-muted; + background: $bg-field; + border: 1px $border; + border-radius: $radius-small; + cursor: hand; + transition: background 250ms ease-out, border-color 250ms ease-out; +} + +.graph-link-label:hover { + background: $bg-hover; + border-color: $border-strong; +} + +.graph-link-label.selected { + foreground: $text-strong; + border-color: $accent; +} diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/ide.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/ide.hss index 1111ca8e6..53b09ee3c 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/ide.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/ide.hss @@ -1,21 +1,51 @@ +/* + * The whole IDE is set in the code font, like its editor and its log: one face for every panel, tab, + * menu and tooltip, narrower than the vanilla one and crisp at any size. Popups are not under + * #ide-root, so they are named here too; this sheet is only loaded by the IDE, so the game's own + * screens keep their font. + * + * Sizes: 9px for text, 8px for what is secondary, 11px for the title of a dialog. + */ + +@import "hollowengine:ui/styles/theme.hss"; + +#ide-root, +.dropdown-popup, +.ui-tooltip, +.vfx-popup, +#cutscene-dialog { + font-family: $font; + font-size: $font-size; +} + +.dropdown-popup .dropdown-item-shortcut { + font-size: $font-size-small; +} + +/* The vanilla drop shadow suits the pixel font only; under the code font it reads as a blur. */ +.text-field { + text-shadow: none; +} + #ide-root { background: none; } .project-tree-panel.drop-target { - background: rgba(88, 123, 174, 0.20); + background: alpha($accent, 0.2); } #ide-toolbar { size: 100% fit; + padding: 1px 0px; gap: 2px; - background: #24272E; + background: $bg-window; } #ide-logo { - size: 24px 24px; + size: $rail $rail; align-items: center center; - border-radius: 4px; + border-radius: $radius; rotate: 0 0 0; scale: 0.9; transition: all 420ms cubic-bezier(0.34, 1.56, 0.64, 1); @@ -29,58 +59,78 @@ .ide-toolbar-divider { size: 1px 14px; margin: 0px 6px; - background: #3a3f4b; + background: $bg-hover; } .ide-toolbar-group { gap: 1px; - background: #1E2127; - border-radius: 4px; + background: $bg-panel; + border-radius: $radius; } .ide-toolbar-button { size: fit fit; margin: 1px; padding: 4px; - border-radius: 3px; - background: #00000000; + border-radius: $radius-small; + background: transparent; transition: background 110ms ease-out; } .ide-toolbar-button:hover { - background: #414653; + background: $bg-hover; } .ide-toolbar-button.active { - background: rgba(67, 106, 154, 0.62); + background: $bg-selected; +} + +#ide-toolbar-search { + size: $rail $rail; + margin: 0px; + padding: 5px; } .ide-toolbar-button-icon { - size: 13px 13px; - tint: #aeb8ca; + size: 12px 12px; + tint: $text-soft; transition: all 110ms ease-out; } .ide-toolbar-button:hover .ide-toolbar-button-icon, .ide-toolbar-button.active .ide-toolbar-button-icon { - tint: #ffffff; + tint: $white; } .ide-panel, .ide-empty-editor, .ide-editor-shell { size: fill fill; margin: 2px; - padding: 4px; - border-radius: 4px; + padding: $panel-inset; + border-radius: $radius-large; gap: 6px; - background: #24272E; + background: $bg-panel; +} + +.ide-editor-shell { + padding: 2px; + background: $bg-field; } .ide-empty-editor { align-items: center center; + background: $bg-field; +} + +/* The game sits in the editor area like any editor: a rounded panel inside the tab bar's stack. */ +.game-viewport { + size: fill fill; + margin: 2px; + border-radius: $radius-large; + background: $bg-window; } .game-viewport-hint { - foreground: #8d96a8; + foreground: $text-muted; text-align: center; } @@ -89,40 +139,87 @@ padding: 0px 4px; gap: 4px; align-items: start center; - background: #31343D; - border-radius: 4px; + background: $bg-field; + border-radius: $radius; } .project-filter-icon { size: 14px 14px; - tint: #FFFFFF; + tint: $white; } .project-filter { grow: 1; margin-top: 2px; align: center center; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + caret-color: $caret; + selection-color: $selection; } .ide-empty-title { - foreground: #cbd3df; + foreground: $text-muted; height: 18px; } -.ide-status { - height: 16px; - margin-right: 6px; +/* The first breadcrumb's icon sits on the rail, under the stripe buttons. */ +#ide-status-bar { + padding: 0px 8px 0px 1px; + gap: 12px; + align-items: start center; + background: $bg-window; +} + +.ide-breadcrumbs { + gap: 2px; + align-items: start center; +} + +.ide-breadcrumb { + size: fit 16px; + padding: 0px 4px; + gap: 4px; + align-items: start center; + border-radius: $radius-small; + background: transparent; + foreground: $text-muted; + transition: + background 100ms ease-out, + foreground 100ms ease-out; +} + +.ide-breadcrumb.last { + foreground: $text-strong; +} + +.ide-breadcrumb.link:hover { + background: $bg-hover; + foreground: $caret; +} + +.ide-breadcrumb-icon { + size: 11px 11px; +} + +.ide-breadcrumb-separator { + size: 8px 8px; + shape: path("M 3 1 L 6 4 L 3 7", 8 8); + fill: none; + stroke: $text-faint; + stroke-width: 1px; +} + +.ide-status-message { max-width: 360px; + foreground: $text-soft; +} + +.ide-status-detail { wrap: false; - text-overflow: dots; - font-size: 10px; - foreground: #8f9bad; + foreground: $text-muted; } -.ide-empty-editor .ide-status { - font-size: 11px; +.ide-status-detail.version { + foreground: $text-faint; } .ide-shortcuts-dialog { @@ -132,44 +229,42 @@ .ide-shortcuts-content { gap: 3px; padding: 0px 8px 0px 0px; - scrollbar: 6px; - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + @apply $thin-scroll; } .ide-shortcuts-title { margin-bottom: 4px; - font-size: 14px; - foreground: #F0F3F8; + font-size: $font-size-title; + foreground: $caret; } .ide-shortcuts-group { margin-top: 6px; - font-size: 10px; - foreground: #8f9bad; + font-size: $font-size; + foreground: $text-muted; } .ide-shortcuts-row { min-height: 20px; padding: 0px 6px; gap: 12px; - background: #2A2E36; - border-radius: 3px; + background: $bg-hover; + border-radius: $radius-small; } .ide-shortcuts-description { width: 0px; - foreground: #d7deea; + foreground: $text-strong; } .ide-shortcuts-keys { wrap: false; padding: 1px 5px; - font-size: 10px; - foreground: #F0F3F8; - background: #3A3F49; - border: 1px #4b5362; - border-radius: 3px; + font-size: $font-size-small; + foreground: $caret; + background: $border; + border: 1px $border; + border-radius: $radius-small; } .ui-profiler-panel { @@ -186,18 +281,18 @@ width: fit; height: fit; padding: 3px 8px; - background: #31343D; - border-radius: 3px; - foreground: #d7deea; + background: $bg-selected; + border-radius: $radius-small; + foreground: $text-strong; } .ui-profiler-button:hover { - background: #414653; + background: $bg-pressed; } .ui-profiler-scroll { width: 100%; - background: #1d2026; + background: $bg-panel; padding: 6px; } @@ -205,17 +300,75 @@ width: fit; height: fit; white-space: pre; - font-size: 10px; - foreground: #d7deea; + font-size: $font-size; + foreground: $text-strong; } -.console-panel { - gap: 4px; +.tool-window-header { + size: 100% 24px; + padding: 0px 4px 0px 8px; + gap: 2px; + align-items: start center; +} + +.tool-window-title { + margin: 0px 8px 0px 4px; +} + +.tool-window-tab { + size: fit 18px; + padding: 0px 8px; + gap: 5px; + align-items: center center; + border-radius: $radius; + background: transparent; + foreground: $text-muted; + transition: + background 110ms ease-out, + foreground 110ms ease-out; +} + +.tool-window-tab:hover { + background: $bg-hover; + foreground: $text-strong; +} + +.tool-window-tab.selected { + background: $row-selected; + foreground: $caret; +} + +.tool-window-tab.selected:hover { + background: $row-selected; +} + +.tool-window-tab-count { + min-width: 12px; + padding: 2px 4px 0px 4px; + text-align: center; + border-radius: $radius-large; + background: $bg-hover; + foreground: $text-soft; +} + +.tool-window-tab-count.has-errors { + background: alpha($danger, 0.16); + foreground: $danger; +} + +.tool-window-tab-count.has-warnings { + background: alpha($warning, 0.14); + foreground: $warning; +} + +.ide-panel.console-panel { + padding: 0px; + gap: 0px; } .console-toolbar { size: fit 22px; - padding: 0px 3px; + padding: 0px $panel-inset; gap: 5px; align-items: start center; } @@ -224,82 +377,76 @@ min-width: 64px; height: 18px; padding: 0px 6px; - background: #24272E; - border: 1px #383D48; - border-radius: 3px; } .console-mode-dropdown { min-width: 84px; height: 20px; padding: 0px 6px; - background: #24272E; - border: 1px #383D48; - border-radius: 3px; + background: $bg-field; + border: 1px $divider; + border-radius: $radius; } .console-command-row { width: 100%; height: fit; min-height: 26px; - padding: 2px 4px; + padding: 3px 6px 5px 6px; gap: 4px; } .console-filter, .console-command { padding: 2px 5px; - background: #24272E; - border: 1px #383D48; - border-radius: 3px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + background: $bg-field; + border: 1px $divider; + border-radius: $radius; + caret-color: $caret; + selection-color: $selection; } -/* The whole log is one read-only field; its colours come from the console's own highlighter. */ +/* + * The whole log is one read-only field; its colors come from the console's own highlighter. It sits + * a shade lighter than the header and the command line around it, like the editor does. + */ .console-log { width: 100%; - padding: 2px 6px; - background: #1E2127; - border-radius: 4px; - foreground: #d7deea; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); - scrollbar: 6px; - scrollbar-track: rgba(255, 255, 255, 0.03); - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; -} - -.console-empty { + padding: 2px 8px; + background: $bg-field; + foreground: $text-strong; + caret-color: $caret; + selection-color: $selection; + @apply $thin-scroll; +} + +.console-empty, .problems-empty { align-items: center center; - background: #1E2127; - border-radius: 4px; - foreground: #7f8a9d; + background: $bg-field; + foreground: $text-faint; } .console-button { size: 22px 22px; - border-radius: 3px; - background: #24272E; - border: 1px #383D48; + border-radius: $radius; + background: transparent; transition: background 110ms ease-out; } .console-button:hover { - background: #5f6b80; + background: $bg-hover; } .console-button.disabled, .console-button.disabled:hover { - background: #2a2d34; + background: transparent; } .console-button-icon { size: 13px 13px; - tint: #d7deea; + tint: $text-strong; } .console-button.disabled .console-button-icon { - tint: #687588; + tint: $text-faint; } .project-context-menu { @@ -328,15 +475,15 @@ .project-name-dialog-title { height: 18px; - foreground: #d7deea; + foreground: $text-strong; } .project-name-dialog-input { - caret-color: #f0f6ff; - background: #1E2127; - border-radius: 4px; - border: 1px #5F6677; - selection-color: rgba(95, 140, 205, 0.46); + caret-color: $caret; + background: $bg-field; + border-radius: $radius; + border: 1px $border-strong; + selection-color: $selection; } .project-name-dialog-actions { @@ -354,22 +501,22 @@ size: fit fit; margin: 2px 0px; padding: 3px; - border-radius: 3px; - background: #00000000; + border-radius: $radius-small; + background: transparent; transition: background 110ms ease-out; } .project-action:hover { - background: #414653; + background: $bg-hover; } .project-action-icon { size: 13px 13px; - tint: #aeb8ca; + tint: $text-soft; } .project-action:hover .project-action-icon { - tint: #ffffff; + tint: $white; } .project-dialog-content { @@ -378,8 +525,8 @@ } .project-dialog-title { - foreground: #F0F3F8; - font-size: 14px; + foreground: $caret; + font-size: $font-size-title; } .project-dialog-field { @@ -389,69 +536,65 @@ .project-dialog-scroll { gap: 7px; padding: 0px 8px 0px 0px; - scrollbar: 6px; - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + @apply $thin-scroll; } .project-dialog-label { - foreground: #8f9bad; - font-size: 10px; + foreground: $text-muted; + font-size: $font-size-small; } .project-dialog-input { padding: 2px 5px; - background: #1E2127; - border: 1px #4b5362; - border-radius: 3px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + background: $bg-field; + border: 1px $border; + border-radius: $radius-small; + caret-color: $caret; + selection-color: $selection; } .project-dialog-dropdown { height: 22px; padding: 0px 6px; - background: #1E2127; - border: 1px #4b5362; - border-radius: 3px; + background: $bg-field; + border: 1px $border; + border-radius: $radius-small; } .project-dialog-dependencies { padding: 3px 5px; gap: 2px; - background: #1E2127; - border-radius: 3px; - scrollbar: 6px; - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + background: $bg-field; + border-radius: $radius-small; + @apply $thin-scroll; } .project-dialog-checkbox-row { gap: 7px; - foreground: #D7DEEA; + foreground: $text-strong; } .project-dialog-text { - foreground: #D7DEEA; - font-size: 11px; + foreground: $text-strong; + font-size: $font-size; line-spacing: 2px; } .project-dialog-hint, .project-dialog-progress { - foreground: #8f9bad; - font-size: 10px; + foreground: $text-muted; + font-size: $font-size-small; line-spacing: 2px; } .project-dialog-warning { - foreground: #ffc266; - font-size: 11px; + foreground: $warning; + font-size: $font-size; line-spacing: 2px; } .project-dialog-error { - foreground: #ff8787; - font-size: 11px; + foreground: $danger; + font-size: $font-size; line-spacing: 2px; } @@ -460,8 +603,8 @@ } .project-dialog-path { - foreground: #aeb8ca; - font-size: 10px; + foreground: $text-soft; + font-size: $font-size-small; } .project-dialog-actions { @@ -472,29 +615,29 @@ .project-dialog-button { min-width: 72px; padding: 0px 10px; - border-radius: 4px; - border: 1px #E7EBF2; - background: #3A3F49; + border-radius: $radius; + border: 1px $text-strong; + background: $border; transition: background 80ms ease-out; } .project-dialog-button:hover { - background: #4A515E; + background: $bg-pressed; } .project-dialog-button.primary { - background: #5F8CCD; - foreground: #FFFFFF; + background: $accent; + foreground: $white; } .project-dialog-button.primary:hover { - background: #6E9BDC; + background: $accent; } .project-dialog-button.disabled, .project-dialog-button.disabled:hover { - background: #2C3038; - foreground: #687588; - border: 1px #4b5362; + background: $bg-hover; + foreground: $text-faint; + border: 1px $border; } .ide-hide-toolbar-dialog { @@ -506,19 +649,19 @@ } .ide-hide-toolbar-dialog-title { - foreground: #F0F3F8; - font-size: 14px; + foreground: $caret; + font-size: $font-size-title; } .ide-hide-toolbar-dialog-message { - foreground: #B9C1CF; - font-size: 11px; + foreground: $text-soft; + font-size: $font-size; line-spacing: 2px; } .ide-hide-toolbar-dialog-checkbox-row { gap: 7px; - foreground: #D7DEEA; + foreground: $text-strong; } .ide-hide-toolbar-dialog-actions { @@ -526,31 +669,31 @@ } .ide-hide-toolbar-dialog-button { - border-radius: 4px; - border: 1px #E7EBF2; - background: #3A3F49; + border-radius: $radius; + border: 1px $text-strong; + background: $border; transition: background 80ms ease-out; } .ide-hide-toolbar-dialog-button:hover { - background: #4A515E; + background: $bg-pressed; } .ide-hide-toolbar-dialog-button:active { - background: #303640; + background: $bg-selected; } .ide-hide-toolbar-dialog-button.primary { - background: #5F8CCD; - foreground: #FFFFFF; + background: $accent; + foreground: $white; } .ide-hide-toolbar-dialog-button.primary:hover { - background: #6E9BDC; + background: $accent; } .ide-hide-toolbar-dialog-button.primary:active { - background: #4D78B8; + background: $accent; } .ide-editor-stack { @@ -568,23 +711,23 @@ padding: 0px 3px; gap: 3px; align-items: center center; - background: #1E1F22; + background: $bg-panel; margin-left: 2px; margin-right: 4px; - border-radius: 3px; + border-radius: $radius-small; } .code-editor-inlay-text { /* A share of the code font, so the hints follow ctrl+wheel along with the text. */ font-size: 85%; - foreground: rgba(142, 153, 171, 0.9); + foreground: alpha($text-muted, 0.9); text-wrap: false; } .code-editor-inlay-icon { size: 9px 9px; align-y: center; - tint: rgba(142, 153, 171, 0.9); + tint: alpha($text-muted, 0.9); transition: all 170ms ease-in-out; } @@ -593,25 +736,25 @@ } .code-editor-inlay.inlay-action:hover { - background: #2B2D31; + background: $bg-hover; } .code-editor-inlay.inlay-action:hover .code-editor-inlay-icon { - tint: #FFFFFF; + tint: $white; } .code-editor-inlay.color-hint { padding: 1px; - background: #00000000; + background: transparent; margin-left: 0px; margin-right: 2px; - border: 1px rgba(255, 255, 255, 0.22); - border-radius: 3px; + border: 1px alpha($white, 0.22); + border-radius: $radius-small; } .code-editor-inlay.color-hint:hover { - background: #00000000; - border: 1px #FFFFFF; + background: transparent; + border: 1px $white; } .code-editor-inlay-swatch { @@ -625,9 +768,8 @@ height: fit; gap: 3px; padding: 3px; - background: #2A2E36; - border: 1px #3a3f4b; - border-radius: 4px; + background: $bg-panel; + border-radius: $radius; } .ide-find-row { @@ -639,89 +781,89 @@ .ide-find-icon { size: 14px 14px; - tint: #aeb8ca; + tint: $text-soft; } .ide-find-input { height: 20px; padding: 2px 5px; - background: #1E2127; - border: 1px #4b5362; - border-radius: 3px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + background: $bg-field; + border: 1px $divider; + border-radius: $radius-small; + caret-color: $caret; + selection-color: $selection; } .ide-find-toggle { size: 20px 20px; - border-radius: 3px; - foreground: #8f9bad; - background: #00000000; + border-radius: $radius-small; + foreground: $text-muted; + background: transparent; transition: all 110ms ease-out; } .ide-find-toggle:hover { - background: #414653; + background: $bg-pressed; } .ide-find-toggle.on { - background: rgba(67, 106, 154, 0.62); - foreground: #f0f6ff; + background: alpha($accent-fill-hover, 0.62); + foreground: $caret; } .ide-find-toggle-label { wrap: false; - font-size: 10px; + font-size: $font-size-small; } .ide-find-status { width: fit; min-width: 58px; wrap: false; - foreground: #8f9bad; + foreground: $text-muted; } .ide-find-status.empty { - foreground: #ff8787; + foreground: $danger; } .ide-find-button { size: 20px 20px; - border-radius: 3px; - background: #00000000; + border-radius: $radius-small; + background: transparent; transition: background 110ms ease-out; } .ide-find-button:hover { - background: #414653; + background: $bg-pressed; } .ide-find-button-icon { size: 11px 11px; - tint: #aeb8ca; + tint: $text-soft; } .ide-find-text-button { width: fit; height: 20px; padding: 0px 8px; - background: #414653; - foreground: #d7deea; - border-radius: 3px; + background: $border; + foreground: $text-strong; + border-radius: $radius-small; } .ide-find-text-button:hover { - background: #5f6b80; + background: $bg-pressed; } .ide-search-overlay { padding: 5px; gap: 4px; - background: #24272E; - border: 1px #5F6677; - border-radius: 6px; - shadow: 2px 4px 12px 0px rgba(0, 0, 0, 0.55); + background: $bg-raised; + border: 1px $border-strong; + border-radius: $radius-large; + shadow: 2px 4px 12px 0px $shadow; } .ide-search-scopes { @@ -735,25 +877,25 @@ width: fit; height: 20px; padding: 0px 10px; - border-radius: 3px; - foreground: #8f9bad; - background: #00000000; + border-radius: $radius-small; + foreground: $text-muted; + background: transparent; transition: all 110ms ease-out; } .ide-search-scope:hover { - background: #414653; + background: $bg-pressed; } .ide-search-scope.on { - background: rgba(67, 106, 154, 0.62); - foreground: #f0f6ff; + background: alpha($accent-fill-hover, 0.62); + foreground: $caret; } .ide-search-status { width: fit; wrap: false; - foreground: #687588; + foreground: $text-faint; } .ide-search-query-row { @@ -761,30 +903,27 @@ height: fit; gap: 5px; padding: 0px 4px; - background: #1E2127; - border: 1px #4b5362; - border-radius: 4px; + background: $bg-field; + border: 1px $border; + border-radius: $radius; } .ide-search-icon { size: 14px 14px; - tint: #aeb8ca; + tint: $text-soft; } .ide-search-input { height: 24px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + caret-color: $caret; + selection-color: $selection; } .ide-search-results { width: 100%; max-height: 320px; gap: 1px; - scrollbar: 6px; - scrollbar-track: rgba(255, 255, 255, 0.03); - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + @apply $thin-scroll; } .ide-search-result { @@ -793,15 +932,15 @@ min-height: 20px; padding: 2px 6px; gap: 6px; - border-radius: 3px; + border-radius: $radius-small; } .ide-search-result:hover { - background: rgba(84, 124, 176, 0.42); + background: $bg-hover; } .ide-search-result.selected { - background: rgba(67, 106, 154, 0.62); + background: $row-selected; } .ide-search-result-icon { @@ -811,139 +950,127 @@ .ide-search-result-name { width: fit; wrap: false; - foreground: #d7deea; + foreground: $text-strong; } .ide-search-result-detail { width: 0px; grow: 1; wrap: false; - font-size: 10px; - foreground: #687588; + font-size: $font-size-small; + foreground: $text-faint; } .ide-search-result-match { - foreground: #ffc266; + foreground: $warning; } .ide-diagnostics-badge { height: fit; - padding: 4px; + margin: 4px 10px 0px 0px; + padding: 3px 5px; align-items: center center; gap: 4px; - background: #31343DBB; - border: 1px rgba(255, 255, 255, 0.16); - border-radius: 4px; - shadow: 1px 2px 1px 0px rgba(0, 0, 0, 0.72); + background: alpha($bg-panel, 0.88); + border: 1px $divider; + border-radius: $radius; translate: 0 0 20px; - foreground: #d7deea; - transition: background 120ms ease-out, translate 120ms ease-out, shadow 120ms ease-out; + foreground: $text-strong; + transition: background 120ms ease-out; } .ide-diagnostics-badge:hover { - background: #31343D; - translate: -1px -1px 20px; - shadow: 2px 3px 1px 0px rgba(0, 0, 0, 0.72); -} - -.ide-diagnostics-badge.has-errors { - foreground: #ff8787; + background: $bg-hover; } -.ide-diagnostics-badge.has-warnings { - foreground: #ffc266; +.problems-file { + size: 100% 22px; + padding: 0px 6px 0px 10px; + gap: 5px; + align-items: start center; } -.ide-diagnostics-text { - translate: 0 1px 0; +.problems-file-icon { + size: 12px 12px; } -.ide-diagnostics-wrap { - width: 100%; - background: #24272E; - border-top: 1px #323846; +.problems-file-name { + margin-right: 4px; + wrap: false; + foreground: $text-strong; } -.ide-diagnostics-resizer { - size: 100% 5px; - background: #5F6677BB; - transition: background 120ms ease-out; +.problem-count-icon { + size: 9px 9px; } -.ide-diagnostics-resizer:hover { - background: #5F6677; +.problem-count { + margin-right: 3px; + foreground: $text-muted; } -.ide-diagnostics-header { - width: 100%; - padding: 3px 6px; - gap: 6px; - background: #2A2E36; - border-bottom: 1px #323846; +.problem-count.has-errors { + foreground: $danger; } -.ide-diagnostics-title { - foreground: #8f9bad; - wrap: false; +.problem-count.has-warnings { + foreground: $warning; } -.ide-diagnostics-panel { - width: 100%; - padding: 4px; - gap: 2px; +.problems-list { + padding: 2px 4px 4px 4px; + background: $bg-field; + @apply $thin-scroll; } -.ide-diagnostic-copy { - size: 16px 16px; - border-radius: 3px; - background: #00000000; +.problem-row { + size: 100% 20px; + padding: 0px 4px 0px 22px; + gap: 6px; + align-items: start center; + border-radius: $radius-small; + background: transparent; transition: background 100ms ease-out; } -.ide-diagnostic-copy:hover { - background: #454B58; -} - -.ide-diagnostic-copy-icon { - size: 11px 11px; - tint: #aeb8ca; +.problem-row:hover { + background: $bg-hover; } -.ide-diagnostic-row { - width: 100%; - height: fit; - padding: 4px 6px; - gap: 8px; - background: #31343D; - border-radius: 3px; +.problem-icon { + size: 10px 10px; } -.ide-diagnostic-severity { - width: 68px; - foreground: #8f9bad; +.problem-message { + width: 0px; + grow: 1; + foreground: $text-strong; } -.ide-diagnostic-location { - width: 54px; - foreground: #8f9bad; +.problem-location { + wrap: false; + foreground: $text-faint; } -.ide-diagnostic-row.error .ide-diagnostic-severity { - foreground: #ff8787; +.problem-copy { + size: 16px 16px; + border-radius: $radius-small; + background: transparent; + transition: background 100ms ease-out; } -.ide-diagnostic-row.warning .ide-diagnostic-severity { - foreground: #ffc266; +.problem-copy:hover { + background: $bg-hover; } -.ide-diagnostic-row.info .ide-diagnostic-severity { - foreground: #78a8ff; +.problem-copy-icon { + size: 10px 10px; + tint: $text-faint; } -.ide-diagnostic-message { - width: 0px; - grow: 1; - foreground: #d7deea; +.problem-row:hover .problem-copy-icon, +.problem-copy:hover .problem-copy-icon { + tint: $text-strong; } .file-context-menu { @@ -969,7 +1096,7 @@ width: 100%; height: 1px; margin: 2px 0px; - background: #3a3f4b; + background: $border; } .drag-ghost { @@ -980,10 +1107,10 @@ .drag-ghost-content { gap: 4px; padding: 2px 6px; - background: #24272ECC; - border: 1px #5F6677; - border-radius: 4px; - shadow: 1px 1px 3px 0px rgba(0, 0, 0, 0.42); + background: alpha($bg-raised, 0.8); + border: 1px $border-strong; + border-radius: $radius; + shadow: 1px 1px 3px 0px $shadow; } .drag-ghost-icon { @@ -992,5 +1119,48 @@ .drag-ghost-label { wrap: false; - foreground: #d7deea; + foreground: $text-strong; +} + +.scene-hint { + size: 100% fit; + padding: 4px 6px; + foreground: $text-faint; + font-size: $font-size-small; +} + +/* The scene window with nothing in it: the same dashed frame the inspector uses, in the middle. */ +.scene-empty { + size: 100% 100%; + padding: 12px; + align-items: center center; +} + +.scene-empty-state { + size: 100% fit; + padding: 22px 12px 18px 12px; + gap: 8px; + align-items: center start; + border: 1px $divider; + border-dash: 5px 4px; + border-radius: $radius-large; +} + +.scene-empty-icon { + size: 22px 22px; + tint: $text-muted; +} + +.scene-empty-text { + size: 100% fit; + text-align: center; + foreground: $text-muted; + font-size: $font-size; +} + +.scene-empty-hint { + size: 100% fit; + text-align: center; + foreground: $text-faint; + font-size: $font-size-small; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/image-editor.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/image-editor.hss index 3cd2e0303..6acd7d7b6 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/image-editor.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/image-editor.hss @@ -1,26 +1,28 @@ +@import "hollowengine:ui/styles/theme.hss"; + .image-editor-root { gap: 5px; padding: 5px; - background: #24272E; + background: $bg-raised; } .image-editor-tool { padding: 3px 5px; align-items: center center; - background: #3A3E48; - border: 1px #555D6C; - border-radius: 4px; - foreground: #D7DEEA; + background: $border; + border: 1px $border; + border-radius: $radius; + foreground: $text-strong; } .image-editor-tool:hover { - background: #454B58; + background: $bg-pressed; } .image-editor-tool.selected { - background: #45648E; - border: 1px #6D94C9; - foreground: #FFFFFF; + background: $bg-selected; + border: 1px $accent; + foreground: $white; } .image-editor-tool.disabled { @@ -34,38 +36,30 @@ } .image-editor-canvas-scroll { - background: #17191E; - scrollbar: 7px; - scrollbar-margin: 0px; - scrollbar-track: rgba(255, 255, 255, 0.03); - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + background: $bg-window; + @apply $thin-scroll; } .image-editor-sidebar { gap: 7px; padding: 8px 10px 8px 12px; - background: #2B2E36; - border-radius: 4px; - scrollbar: 6px; - scrollbar-margin: 0px; - scrollbar-track: rgba(255, 255, 255, 0.02); - scrollbar-thumb: rgba(155, 169, 194, 0.38); - scrollbar-thumb-radius: 3px; + background: $bg-hover; + border-radius: $radius; + @apply $thin-scroll; } .image-editor-sidebar-title { height: fit; margin: 4px 0px 0px 0px; - foreground: #EEF3FB; + foreground: $caret; } .image-editor-setting { - foreground: #D7DEEA; + foreground: $text-strong; } .image-editor-info { height: fit; - foreground: #9FAABD; + foreground: $text-soft; text-wrap: true; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/inspector.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/inspector.hss index 1530b0fce..cc8f322f1 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/inspector.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/inspector.hss @@ -1,109 +1,179 @@ /* - * The look of every inspector in the engine: entity components, the animator, the rig, the cutscene - * timeline, the model and sound editors. Editors bring their own chrome and canvas; everything that - * shows a property lives here, so a field looks the same wherever it is opened. + * The look of every inspector in the engine: entity components, the animator, the rig, the effect + * editor, the timeline selection, the model and sound editors. Editors bring their own chrome and + * canvas; everything that shows a property lives here, so a field looks the same wherever it is opened. * - * Orange marks what the pointer acts on, blue marks structure. + * Colors, sizes and the font come from theme.hss: the panel on $bg-panel, fields and headings on + * $bg-field, $accent for whatever has focus or is picked. */ +@import "hollowengine:ui/styles/theme.hss"; + + .insp-panel { size: 100% 100%; - gap: 8px; - padding: 10px; - background: #1E1F22; - border: 1px #31343D; - border-radius: 4px; + gap: 5px; + padding: $panel-inset; + background: $bg-panel; + border-radius: $radius-large; + font-family: $font; + @apply $thin-scroll; } .insp-head { size: 100% fit; - gap: 6px; + gap: 5px; + padding: 1px 2px 3px 2px; align-items: start center; } .insp-icon { - size: 14px 14px; - tint: #8B93A5; + size: 12px 12px; + tint: $text-soft; } .insp-title { size: fit fit; grow: 1; - foreground: #E4E9F4; - font-size: 11px; + foreground: $text-strong; + font-size: 10px; wrap: false; text-overflow: dots; } .insp-subtitle { size: fit fit; - foreground: #8B93A5; - font-size: 9px; + foreground: $text-faint; + font-size: $font-size-small; wrap: false; text-overflow: dots; } .insp-body { size: 100% fit; - gap: 8px; + gap: 3px; +} + +.insp-empty-state { + size: 100% fit; + margin-top: 24px; + padding: 22px 12px 18px 12px; + gap: 10px; + align-items: center start; + border: 1px $divider; + border-dash: 5px 4px; + border-radius: $radius-large; +} + +/* A shape rather than an image: one opaque stroke, so where lines cross nothing doubles up. */ +.insp-empty-icon { + size: 26px 28px; + shape: path("M 5.3 12.34 C 5.48 12.44 5.69 12.5 5.9 12.5 C 6.11 12.5 6.32 12.44 6.5 12.34 L 10.7 9.94 C 10.88 9.83 11.03 9.68 11.14 9.5 C 11.24 9.32 11.3 9.11 11.3 8.9 L 11.3 4.1 C 11.3 3.89 11.24 3.68 11.14 3.5 C 11.03 3.32 10.88 3.17 10.7 3.06 L 6.5 0.66 C 6.32 0.56 6.11 0.5 5.9 0.5 C 5.69 0.5 5.48 0.56 5.3 0.66 L 1.1 3.06 C 0.92 3.17 0.77 3.32 0.66 3.5 C 0.56 3.68 0.5 3.89 0.5 4.1 L 0.5 8.9 C 0.5 9.11 0.56 9.32 0.66 9.5 C 0.77 9.68 0.92 9.83 1.1 9.94 Z M 5.9 12.5 L 5.9 6.5 M 0.67 3.5 L 5.9 6.5 L 11.13 3.5 M 3.2 1.86 L 8.6 4.95", 12 13); + fill: none; + stroke: $text-muted; + stroke-width: 1.2px; } .insp-empty { size: 100% fit; - padding: 12px 6px; - foreground: #5F6677; - font-size: 9px; + text-align: center; + foreground: $text-faint; + font-size: $font-size-small; } /* ---- sections ---- */ .insp-section { size: 100% fit; - gap: 5px; + gap: 3px; } +/* A plain section: a title over a hairline. */ .insp-section-title { size: 100% fit; - foreground: #6E9FE0; - font-size: 10px; + foreground: $text-muted; + font-size: $font-size-small; wrap: false; text-overflow: dots; } .insp-section-rule { size: 100% 1px; - background: #31343D; + background: $bg-hover; } .insp-section-body { size: 100% fit; - gap: 6px; + gap: 3px; } .insp-section.boxed { - padding: 8px; - background: #24272E; - border-radius: 5px; - border: 1px #31343D; + padding: 6px; + background: $bg-field; + border-radius: $radius; +} + +.insp-section-head { + size: 100% fit; + min-height: 18px; + gap: 5px; + padding: 2px 5px; + align-items: start center; + background: $bg-field; + border-radius: $radius; + hoverable: true; + clickable: true; + transition: background 110ms ease-out; +} + +.insp-section-head:hover { background: $bg-hover; } + +.insp-section-head[expanded="true"] { background: $bg-raised; } + +.insp-section-head .insp-section-title { + size: fit fit; + grow: 1; + foreground: $text; + font-size: $font-size; +} + +.insp-section-head:hover .insp-section-title { foreground: $white; } + +.insp-section-icon { + size: 11px 11px; + tint: $text-muted; + transition: tint 110ms ease-out; +} + +.insp-section-head:hover .insp-section-icon, +.insp-section-head[expanded="true"] .insp-section-icon { tint: $accent; } + +.insp-section-head .insp-arrow { + size: 8px 8px; + margin: 0px; +} + +.insp-section-fold .insp-section-body { + padding: 1px 1px 5px 4px; } /* ---- fields ---- */ .insp-field { size: 100% fit; - gap: 3px; + gap: 2px; } .insp-field-head { size: 100% fit; - gap: 6px; + gap: 5px; align-items: start center; } .insp-label { size: 100% fit; - foreground: #8B93A5; - font-size: 9px; + foreground: $text-muted; + font-size: $font-size-small; } .insp-help { @@ -118,13 +188,13 @@ .insp-hint { size: 100% fit; - foreground: #5F6677; - font-size: 8px; + foreground: $text-faint; + font-size: $font-size-small; } .insp-readonly { size: 100% fit; - min-height: 16px; + min-height: 15px; gap: 6px; align-items: start center; } @@ -132,48 +202,150 @@ .insp-readonly-label { size: fit fit; grow: 1; - foreground: #8B93A5; - font-size: 9px; + foreground: $text-muted; + font-size: $font-size-small; } .insp-readonly-value { size: fit fit; - foreground: #C4CBDA; - font-size: 9px; + foreground: $text; + font-size: $font-size-small; wrap: false; text-overflow: dots; } .insp-input-row { size: 100% fit; - gap: 4px; + gap: 3px; align-items: start center; } .insp-input { size: 100% fit; - min-height: 22px; - padding: 5px 6px; - background: #16181C; - border-radius: 4px; - border: 1px #31343D; - foreground: #C4CBDA; - font-size: 9px; + min-height: 18px; + padding: 3px 5px; + background: $bg-field; + border-radius: $radius-small; + border: 1px $divider; + foreground: $text; + font-size: $font-size; + caret-color: $caret; + selection-color: $selection; + text-shadow: none; + transition: border 110ms ease-out; } -.insp-input:focus { border: 1px #D77F1C; } +.insp-input:hover { border: 1px $border; } + +.insp-input:focus { border: 1px $accent; } +/* A field inside a bar of its own, such as a search: no frame until the pointer is on it. */ .insp-input.flat { + min-height: 16px; + padding: 1px 4px; background: none; - border-width: 0px; + border: 1px transparent; } +.insp-input.flat:hover { border: 1px $border; } + +.insp-input.flat:focus { border: 1px $accent; } + .insp-input.numeric { text-align: right; } +/* ---- inline fields ---- */ + +.insp-inline-label { + foreground: $text-muted; + font-size: $font-size-small; + wrap: false; + text-overflow: dots; +} + +.insp-inline-label.hinted { + hoverable: true; +} + +.insp-inline-label.hinted:hover { foreground: $text; } + +.insp-input.insp-inline-input { + min-height: 15px; + padding: 1px 4px; +} + +.insp-icon-button.insp-inline-icon { + size: 11px 11px; +} + +/* ---- effect editor ---- */ + +.vfx-popup { + padding: 8px; + gap: 6px; + background: $bg-raised; + border: 1px $border; + border-radius: $radius; + shadow: 1px 1px 3px 0px $shadow; +} + +/* A field the timeline writes: read-only, and tinted like the accent so it reads as "keyed". */ +.vfx-driven { + background: $bg-field; + border: 1px alpha($accent, 0.5); + foreground: $text-soft; +} + +.vfx-curve-dialog { + padding: 10px; +} + +.vfx-gizmo-readout { + padding: 1px 4px; + background: alpha($bg-window, 0.8); + border-radius: $radius-small; + foreground: $text-strong; + font-size: $font-size; +} + +.vfx-detail { + size: 100% fit; + gap: 3px; + padding: 3px 0px 3px 6px; + border-left: 1px $divider; +} + +.vfx-node-head { + size: 100% fit; + gap: 4px; + align-items: start center; +} + +/* Whether a renderer draws on particles or once; which one depends on where it sits in the tree. */ +.vfx-placement { + size: 12px 12px; + tint: $text-faint; + hoverable: true; +} + +.vfx-placement.active { tint: $accent; } + +.vfx-material-preview { + size: 100% 96px; + border-radius: $radius; + background: $bg-window; + clip: true; +} + +.vfx-module-head { + size: 100% fit; + gap: 4px; + align-items: start center; +} + .insp-check-row { size: 100% fit; - min-height: 20px; - gap: 6px; + min-height: 16px; + gap: 5px; align-items: start center; hoverable: true; clickable: true; @@ -182,27 +354,29 @@ .insp-check-label { size: fit fit; grow: 1; - foreground: #C4CBDA; - font-size: 9px; + foreground: $text; + font-size: $font-size-small; } .insp-checkbox { - size: 13px 13px; + size: 11px 11px; + checkbox-active: $accent; + checkbox-mark: $white; } .insp-slider { grow: 1; } .insp-slider-value { size: fit fit; - min-width: 44px; - foreground: #C4CBDA; - font-size: 9px; + min-width: 40px; + foreground: $text; + font-size: $font-size-small; wrap: false; } .insp-vector { size: 100% fit; - gap: 4px; + gap: 3px; } .insp-vector-cell { @@ -213,33 +387,33 @@ .insp-vector-label { size: fit fit; - foreground: #5F6677; - font-size: 8px; + foreground: $text-faint; + font-size: $font-size-small; } .insp-nested { size: 100% fit; - gap: 4px; - padding: 5px; - background: rgba(42, 45, 53, 0.55); - border-radius: 5px; + gap: 3px; + padding: 4px; + background: $bg-field; + border-radius: $radius; } .insp-nested-head { size: 100% fit; - gap: 5px; + gap: 4px; align-items: start center; } .insp-nested-body { size: 100% fit; - gap: 6px; - padding: 0px 0px 0px 6px; + gap: 4px; + padding: 0px 0px 0px 5px; } .insp-list-item { size: 100% fit; - gap: 5px; + gap: 4px; align-items: start start; } @@ -251,117 +425,162 @@ } .insp-pill { - padding: 3px 8px; - background: #24272E; - border-radius: 9px; - border: 1px #31343D; + padding: 2px 6px; + background: $bg-field; + border-radius: $radius-small; + border: 1px $divider; hoverable: true; clickable: true; - transition: background 140ms ease-out, border 140ms ease-out; + transition: background 110ms ease-out, border 110ms ease-out; } -.insp-pill:hover { background: #31343D; } +.insp-pill:hover { background: $bg-hover; } .insp-pill.active { - background: rgba(215, 127, 28, 0.22); - border: 1px #D77F1C; + background: alpha($accent, 0.2); + border: 1px $accent; } .insp-pill-label { size: fit fit; - foreground: #C4CBDA; - font-size: 8px; + foreground: $text-soft; + font-size: $font-size-small; wrap: false; } -.insp-pill.active .insp-pill-label { foreground: #E8912E; } +.insp-pill.active .insp-pill-label { foreground: $white; } .insp-icon-button { - size: 16px 16px; - tint: #8B93A5; + size: 14px 14px; + tint: $text-muted; hoverable: true; clickable: true; - transition: tint 140ms ease-out; + transition: tint 110ms ease-out; } -.insp-icon-button:hover { tint: #E8912E; } +.insp-icon-button:hover { tint: $white; } + +.insp-icon-button.active { tint: $accent; } -.insp-icon-button.active { tint: #E8912E; } +.insp-icon-button.danger:hover { tint: $danger; } .insp-pick-button { - size: 20px 20px; - padding: 2px; - background: #24272E; - border-radius: 4px; - border: 1px #31343D; + size: 18px 18px; + padding: 3px; + background: $bg-field; + border-radius: $radius-small; + border: 1px $divider; } +.insp-pick-button:hover { background: $bg-hover; } + .insp-button { size: 100% fit; - min-height: 24px; - gap: 6px; - padding: 5px 10px; + min-height: 18px; + gap: 5px; + padding: 3px 8px; align-items: center center; - background: #31343D; - border-radius: 6px; - border: 1px #3B3F49; + background: $bg-hover; + border-radius: $radius; hoverable: true; clickable: true; - transition: background 140ms ease-out; + transition: background 110ms ease-out; } -.insp-button:hover { background: #3B3F49; } +.insp-button:hover { background: $bg-pressed; } -.insp-button.primary { - background: #D77F1C; - border: 1px #E8912E; -} +.insp-button.primary { background: $accent-fill; } -.insp-button.primary:hover { background: #E8912E; } +.insp-button.primary:hover { background: $accent-fill-hover; } -.insp-button.danger { - background: rgba(194, 87, 87, 0.18); - border: 1px #C25757; -} +.insp-button.danger { background: $danger-soft; } -.insp-button.danger:hover { background: rgba(194, 87, 87, 0.32); } +.insp-button.danger:hover { background: alpha($danger, 0.32); } .insp-button-icon { - size: 13px 13px; - tint: #C4CBDA; + size: 11px 11px; + tint: $text; } -.insp-button.primary .insp-button-icon { tint: #1E1F22; } +.insp-button.primary .insp-button-icon { tint: $white; } .insp-button-label { size: fit fit; - foreground: #C4CBDA; - font-size: 9px; + foreground: $text; + font-size: $font-size-small; + wrap: false; +} + +.insp-button.primary .insp-button-label { foreground: $white; } + +.insp-button.danger .insp-button-label { foreground: $danger; } + +/* ---- asset picker ---- */ + +.insp-dialog { + gap: 6px; + padding: 10px; + background: $bg-raised; + border-radius: $radius-large; + border: 1px $border; + shadow: 2px 2px 10px 0px $shadow; + font-family: $font; +} + +.insp-dialog-head { + size: 100% fit; + gap: 6px; + align-items: start center; +} + +.insp-dialog-title { + size: fit fit; + foreground: $text-strong; + font-size: 10px; wrap: false; + text-overflow: dots; } -.insp-button.primary .insp-button-label { foreground: #1E1F22; } +.insp-search { + size: 100% fit; + min-height: 20px; + gap: 5px; + padding: 1px 5px; + align-items: start center; + background: $bg-field; + border-radius: $radius; +} -.insp-button.danger .insp-button-label { foreground: #E79797; } +.insp-search-icon { + size: 11px 11px; + tint: $text-muted; +} + +.insp-asset-tree { + width: 100%; + background: $bg-field; + border-radius: $radius; + padding: 3px; +} /* ---- bone picker ---- */ .insp-bone { - padding: 4px 6px; + padding: 3px 5px; gap: 4px; - background: #16181C; - border-radius: 4px; - border: 1px #31343D; - foreground: #C4CBDA; - transition: border 140ms ease-out; + background: $bg-field; + border-radius: $radius-small; + border: 1px $divider; + foreground: $text; + transition: border 110ms ease-out; } -.insp-bone:hover { border: 1px #D77F1C; } +.insp-bone:hover { border: 1px $accent; } -.insp-bone.empty .insp-bone-label { foreground: #5F6677; } +.insp-bone.empty .insp-bone-label { foreground: $text-faint; } .insp-bone-label { - font-size: 9px; + font-size: $font-size-small; } .insp-bone-popup { @@ -383,16 +602,10 @@ margin: 0px 2px; shape: path("M 3 2 L 8 6 L 3 10", 12 12); fill: none; - stroke: #7f8a9d; + stroke: $text-muted; stroke-width: 1.5px; transition: rotate 120ms ease-out; } .insp-arrow[expanded="false"] { rotate: 0px 0px 0px; } .insp-arrow[expanded="true"] { rotate: 0px 0px 90px; } - -.insp-section-head { - size: 100% fit; - gap: 4px; - align-items: start center; -} diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/model-editor.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/model-editor.hss index 464629a32..884896e1e 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/model-editor.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/model-editor.hss @@ -1,6 +1,8 @@ -/* Styling for the model editor dock panel (client/gui/scripting/panels/ModelEditorPanel.kt). - Palette follows ColorTheme: bg #1E1F22/#24272E/#31343D, accent #5F6677, text #C4CBDA, - main accent #D77F1C. */ +/* Styling for the model editor dock panel (client/ui/ide/panels/ModelEditorPanel.kt). Colors come + from theme.hss. */ + +@import "hollowengine:ui/styles/theme.hss"; + .model-editor-root { size: fill fill; @@ -11,9 +13,9 @@ grow: 1; size: 0px 100%; min-size: 0px 0px; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; clip: true; } @@ -22,11 +24,11 @@ margin: 8px; padding: 3px 9px; layer: 1000; - foreground: #C4CBDA; + foreground: $text; font-size: 11px; - background: rgba(30, 32, 38, 0.9); - border-radius: 6px; - border: 1px #31343D; + background: alpha($bg-field, 0.9); + border-radius: $radius-large; + border: 1px $bg-selected; wrap: false; backdrop-filter: blur(9px); } @@ -43,9 +45,9 @@ .model-chip { size: 28px 28px; align-items: center center; - background: rgba(36, 39, 46, 0.82); - border-radius: 7px; - border: 1px #31343D; + background: alpha($bg-raised, 0.82); + border-radius: $radius-large; + border: 1px $bg-selected; hoverable: true; clickable: true; transition: background 140ms ease-out, border 140ms ease-out; @@ -53,21 +55,21 @@ } .model-chip:hover { - background: rgba(49, 52, 61, 0.88); + background: $bg-hover; } .model-chip.selected { - background: rgba(215, 127, 28, 0.72); - border: 1px #D77F1C; + background: $row-selected; + border: 1px $accent; } .model-chip-icon { size: 16px 16px; - tint: #C4CBDA; + tint: $text; } .model-chip-icon.selected { - tint: #D77F1C; + tint: $accent; } /* ---- animation control bar ---- */ @@ -79,13 +81,13 @@ gap: 7px; width: 300px; layer: 1000; - background: #24272E; - border-radius: 8px; - border: 1px #31343D; + background: $bg-raised; + border-radius: $radius-large; + border: 1px $bg-selected; } .model-anim-title { - foreground: #8B93A5; + foreground: $text-muted; font-size: 10px; height: 13px; } @@ -100,51 +102,51 @@ grow: 1; height: 26px; padding: 0px 8px; - background: #1E1F22; - border-radius: 6px; - border: 1px #31343D; + background: $bg-panel; + border-radius: $radius-large; + border: 1px $bg-selected; } .model-play { size: 26px 26px; align-items: center center; - background: #31343D; - border-radius: 6px; - border: 1px #3B3F49; + background: $bg-selected; + border-radius: $radius-large; + border: 1px $border; hoverable: true; clickable: true; transition: background 140ms ease-out, border 140ms ease-out; } .model-play:hover { - background: #3B3F49; + background: $bg-pressed; } .model-play.active { - background: rgba(215, 127, 28, 0.9); - border: 1px #E8912E; + background: $accent-soft; + border: 1px $accent; } .model-play-icon { size: 14px 14px; - tint: #C4CBDA; + tint: $text; } .model-play.active .model-play-icon { - tint: #1E1F22; + tint: $bg-panel; } .model-progress-track { width: 100%; height: 4px; - background: #1E1F22; + background: $bg-panel; border-radius: 2px; clip: true; } .model-progress-fill { height: 100%; - background: #D77F1C; + background: $accent; border-radius: 2px; } @@ -152,18 +154,18 @@ .model-splitter { size: 5px 100%; - background: rgba(95, 102, 119, 0.0); - border-radius: 3px; + background: transparent; + border-radius: $radius-small; hoverable: true; transition: background 140ms ease-out; } .model-splitter:hover { - background: rgba(215, 127, 28, 0.55); + background: $accent; } .model-splitter:dragging { - background: #D77F1C; + background: $accent; } /* ---- sidebar (collapsible sections) ---- */ @@ -172,9 +174,9 @@ min-size: 190px 0px; gap: 4px; padding: 6px; - background: #24272E; - border-radius: 6px; - border: 1px #31343D; + background: $bg-raised; + border-radius: $radius-large; + border: 1px $bg-selected; clip: true; } @@ -187,12 +189,12 @@ /* The node-tree eye icon is a button: dim by default, accent on hover. */ .model-node-tree .tree-icon-button { - tint: #8B93A5; + tint: $text-muted; transition: tint 120ms ease-out; } .model-node-tree .tree-icon-button:hover { - tint: #D77F1C; + tint: $accent; } .model-anim-item { @@ -200,9 +202,9 @@ height: 22px; padding: 0px 8px; align-items: start center; - background: rgba(255, 255, 255, 0); - foreground: #AEB8CA; - border-radius: 4px; + background: transparent; + foreground: $text-soft; + border-radius: $radius; hoverable: true; clickable: true; transition: @@ -211,17 +213,17 @@ } .model-anim-item:hover { - background: rgba(255, 255, 255, 0.07); - foreground: #ffffff; + background: $bg-hover; + foreground: $white; } .model-anim-item.selected { - background: rgba(215, 127, 28, 0.28); - foreground: #ffffff; + background: $row-selected; + foreground: $white; } .model-empty { - foreground: #5F6677; + foreground: $border-strong; font-size: 11px; padding: 2px 4px; } @@ -230,8 +232,8 @@ height: fit; padding: 5px 8px; align: center end; - foreground: #ff8787; - background: rgba(36, 39, 46, 0.88); - border: 1px rgba(255, 135, 135, 0.45); - border-radius: 4px; + foreground: $danger; + background: alpha($bg-raised, 0.88); + border: 1px alpha($danger, 0.45); + border-radius: $radius; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/notifications.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/notifications.hss index d1b386003..b35fd7030 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/notifications.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/notifications.hss @@ -1,24 +1,30 @@ /* * The notification stack in the bottom-right corner. Each card is bordered with its own kind's - * gradient, matching the icon it carries. + * gradient, matching the icon it carries; the rest follows the IDE's popups, compact and quiet, and + * is set in the same code font as the IDE. */ +@import "hollowengine:ui/styles/theme.hss"; + + .notif-stack { + font-family: $font; + font-size: $font-size; size: fit fit; - gap: 8px; - margin: 12px; + gap: 6px; + margin: 10px; align: end end; } .notif { - size: 320px fit; - gap: 6px; - padding: 10px 12px; - background: rgba(24, 26, 31, 0.94); - border-radius: 10px; - border: 1px #31343D; + size: 280px fit; + gap: 5px; + padding: 7px 8px 7px 10px; + background: alpha($bg-field, 0.96); + border-radius: $radius-large; + border: 1px $divider; backdrop-filter: blur(8px); - shadow: 0px 4px 14px rgba(0, 0, 0, 0.45); + shadow: 0px 6px 16px 0px $shadow; } .notif.success { border: 1px linear-gradient(135deg, #9BF763, #3D5A24); } @@ -29,62 +35,57 @@ .notif-head { size: 100% fit; - gap: 10px; + gap: 8px; align-items: start center; } .notif-icon { - size: 26px 26px; + size: 16px 16px; } .notif-title { size: fit fit; - foreground: #E4E9F4; - font-size: 11px; + foreground: $text-strong; text-overflow: dots; } .notif-action { size: fit fit; - min-width: 62px; - min-height: 26px; - padding: 4px 10px; - background: #2A2D35; - border-radius: 7px; - border: 1px #3B3F49; + min-height: 18px; + padding: 2px 8px; + background: $bg-hover; + border-radius: $radius; hoverable: true; clickable: true; - transition: background 140ms ease-out; + transition: background 120ms ease-out; } -.notif-action:hover { background: #3B3F49; } +.notif-action:hover { background: $bg-pressed; } .notif-action-label { align: center center; size: fit fit; - foreground: #C4CBDA; - font-size: 10px; + foreground: $text-strong; wrap: false; } .notif-close { - size: 24px 24px; - padding: 6px; - background: #2A2D35; - border-radius: 7px; - border: 1px #3B3F49; - tint: #8B93A5; - transition: tint 140ms ease-out, background 140ms ease-out; + size: 16px 16px; + padding: 4px; + background: transparent; + border-radius: $radius; + tint: $text-faint; + transition: tint 120ms ease-out, background 120ms ease-out; } .notif-close:hover { - background: #3B3F49; - tint: #E4E9F4; + background: $bg-hover; + tint: $text-strong; } .notif-track { - size: 100% 4px; - background: #2A2D35; + size: 100% 3px; + background: $bg-hover; border-radius: 2px; clip: true; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/shader-graph.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/shader-graph.hss new file mode 100644 index 000000000..fa8b4e663 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/shader-graph.hss @@ -0,0 +1,202 @@ +@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. + The title bar sits 2px inside the frame, so its corners are the frame's less those 2px. */ + +$category-input: #5B8FE0; +$category-math: #7C83F0; +$category-vector: #E0B347; +$category-uv: #4FC48B; +$category-texture: #F07D3A; +$category-procedural: #B072E8; +$category-normal: #4FB8C9; +$category-output: #E5566A; + +.graph-node.sg-node { + background: $bg-panel; + border: 1px $border; + border-radius: $radius-large; + shadow: 0px 3px 10px 0px $shadow; +} + +.graph-node.sg-node:hover { + border-color: $border-strong; +} + +.graph-node.sg-node.selected { + border-color: $accent; + shadow: 0px 0px 5px 0px alpha($accent, 0.22); +} + +.graph-node.sg-node.error { + border-color: $danger; +} + +.sg-node-head { + padding: 0px 6px; + background: alpha($category-math, 0.34); + border-radius: $radius; +} + +.sg-input .sg-node-head { background: alpha($category-input, 0.34); } +.sg-vector .sg-node-head { background: alpha($category-vector, 0.3); } +.sg-uv .sg-node-head { background: alpha($category-uv, 0.3); } +.sg-texture .sg-node-head { background: alpha($category-texture, 0.32); } +.sg-procedural .sg-node-head { background: alpha($category-procedural, 0.34); } +.sg-normal .sg-node-head { background: alpha($category-normal, 0.32); } +.sg-output .sg-node-head { background: alpha($category-output, 0.36); } + +.sg-node-row { + gap: 4px; +} + +.sg-pin-label { + font-size: $font-size-small; + foreground: $text-soft; + text-wrap: false; + text-overflow: dots; +} + +.sg-pin-label.output { + foreground: $text; +} + +/* What an unlinked input reads instead of a value, such as the UV. */ +.sg-pin-hint { + font-size: $font-size-small; + foreground: $text-faint; + text-wrap: false; +} + +/* The name of a value, which a drag along changes it by. */ +.sg-scrub { + cursor: ew-resize; + transition: foreground 110ms ease-out; +} + +.sg-scrub:hover { + foreground: $text-strong; +} + +.sg-fields { + gap: 2px; +} + +.sg-field { + padding: 1px 3px; + background: $bg-window; + border: 1px $divider; + border-radius: $radius-small; + foreground: $text; + font-family: $font; + font-size: $font-size-small; + caret-color: $caret; + selection-color: $selection; + text-shadow: none; + transition: border 110ms ease-out; +} + +.sg-field:hover { + border-color: $border; +} + +.sg-field:focus { + border-color: $accent; +} + +.sg-expression { + font-size: $font-size; + padding: 2px 4px; +} + +/* What a choice is set to; a press opens its values. */ +.sg-chip { + padding: 2px 5px; + background: $bg-window; + border: 1px $divider; + border-radius: $radius-small; + foreground: $text; + font-size: $font-size-small; + text-wrap: false; + text-overflow: dots; + cursor: hand; + transition: border 110ms ease-out, background 110ms ease-out; +} + +.sg-chip:hover { + background: $bg-hover; + border-color: $border; +} + +.sg-toggle { + gap: 5px; + cursor: hand; +} + +.sg-checkbox { + size: 10px 10px; + checkbox-active: $accent; + checkbox-mark: $white; +} + +.sg-node-preview { + background: $bg-window; + border-radius: $radius-small; +} + +.sg-pin { + background: $bg-panel; + border: 2px $text-muted; + border-radius: 5px; + cursor: hand; + transition: background 150ms ease-out; +} + +.sg-pin:hover { + background: $text-strong; +} + +.sg-pin.sg-type-float { border-color: #9EA8BD; } +.sg-pin.sg-type-vec2 { border-color: #78CC8F; } +.sg-pin.sg-type-vec3 { border-color: #F5D161; } +.sg-pin.sg-type-vec4 { border-color: #E685C7; } +.sg-pin.sg-type-texture { border-color: #F28C59; } + +.sg-pin.connected.sg-type-float { background: #9EA8BD; } +.sg-pin.connected.sg-type-vec2 { background: #78CC8F; } +.sg-pin.connected.sg-type-vec3 { background: #F5D161; } +.sg-pin.connected.sg-type-vec4 { background: #E685C7; } +.sg-pin.connected.sg-type-texture { background: #F28C59; } + +/* A number: dragged sideways it changes, clicked it is typed into. */ +.sg-number { + cursor: ew-resize; + text-wrap: false; + text-overflow: dots; +} + +.sg-swatch { + border: 1px $divider; + border-radius: $radius-small; + cursor: hand; + transition: border 110ms ease-out; +} + +.sg-swatch:hover { + border-color: $border-strong; +} + +.sg-color-popup { + padding: 8px; + background: $bg-raised; + border: 1px $border; + border-radius: $radius; + shadow: 1px 1px 3px 0px $shadow; + font-family: $font; + font-size: $font-size; +} + +.sg-color-popup-body { + gap: 6px; +} diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/sounds-editor.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/sounds-editor.hss index 955658bd3..04c1246c9 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/sounds-editor.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/sounds-editor.hss @@ -1,7 +1,9 @@ +@import "hollowengine:ui/styles/theme.hss"; + .sounds-editor-root { gap: 6px; padding: 6px; - background: #24272E; + background: $bg-raised; } .sounds-editor-toolbar { @@ -12,78 +14,70 @@ .sounds-editor-title { height: fit; - foreground: #EEF3FB; + foreground: $caret; font-size: 12px; text-wrap: false; } .sounds-editor-status { height: fit; - foreground: #6FBF8A; + foreground: $success; font-size: 10px; text-wrap: false; } .sounds-editor-status.dirty { - foreground: #E0A94B; + foreground: $accent; } .sounds-editor-status-dot { size: 6px 6px; - border-radius: 3px; - background: #6FBF8A; + border-radius: $radius-small; + background: $success; transition: background 150ms ease-out; } .sounds-editor-status-dot.dirty { - background: #E0A94B; + background: $accent; } .sounds-editor-list { gap: 5px; padding: 6px; - background: #2B2E36; - border-radius: 4px; + background: $bg-hover; + border-radius: $radius; } .sounds-editor-list-scroll { gap: 2px; - scrollbar: 6px; - scrollbar-margin: 0px; - scrollbar-track: rgba(255, 255, 255, 0.02); - scrollbar-thumb: rgba(155, 169, 194, 0.38); - scrollbar-thumb-radius: 3px; + @apply $thin-scroll; } .sounds-editor-detail { gap: 7px; padding: 8px 10px; - background: #2B2E36; - border-radius: 4px; - scrollbar: 6px; - scrollbar-margin: 0px; - scrollbar-track: rgba(255, 255, 255, 0.02); - scrollbar-thumb: rgba(155, 169, 194, 0.38); - scrollbar-thumb-radius: 3px; + background: $bg-hover; + border-radius: $radius; + @apply $thin-scroll; } .sounds-editor-event { padding: 0px 4px 0px 8px; gap: 4px; - background: #00000000; - foreground: #C6CEDC; - border-radius: 4px; + background: transparent; + foreground: $text; + border-radius: $radius; transition: background 120ms ease-out, foreground 120ms ease-out; } .sounds-editor-event:hover { - background: rgba(255, 255, 255, 0.07); - foreground: #FFFFFF; + background: alpha($white, 0.07); + foreground: $white; } .sounds-editor-event.selected { - background: rgba(88, 123, 174, 0.32); - foreground: #FFFFFF; + background: $row-selected; + foreground: $white; } .sounds-editor-event-label { @@ -94,7 +88,7 @@ .sounds-editor-event-count { height: fit; - foreground: #8B96A9; + foreground: $text-muted; font-size: 9px; text-wrap: false; } @@ -103,7 +97,7 @@ .sounds-editor-placeholder { height: fit; padding: 8px 4px; - foreground: #8B96A9; + foreground: $text-muted; text-wrap: true; } @@ -118,20 +112,20 @@ .sounds-editor-section-title { height: fit; - foreground: #EEF3FB; + foreground: $caret; text-wrap: false; } .sounds-editor-label { height: fit; - foreground: #AEB8CA; + foreground: $text-soft; font-size: 10px; text-wrap: false; } .sounds-editor-value { height: fit; - foreground: #C6CEDC; + foreground: $text; font-size: 10px; align: end center; text-wrap: false; @@ -140,35 +134,35 @@ .sounds-editor-search, .sounds-editor-field { padding: 3px 5px; - background: #31343D; - border: 1px #454B58; - border-radius: 3px; - foreground: #E4E9F2; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + background: $bg-selected; + border: 1px $border; + border-radius: $radius-small; + foreground: $text-strong; + caret-color: $caret; + selection-color: $selection; } .sounds-editor-sound-card { size: 100% fit; gap: 5px; padding: 7px; - background: #313540; - border: 1px #3E434F; - border-radius: 4px; + background: $bg-selected; + border: 1px $border; + border-radius: $radius; } .sounds-editor-button { - background: #3A3E48; - border: 1px #555D6C; - border-radius: 4px; - foreground: #D7DEEA; + background: $border; + border: 1px $border; + border-radius: $radius; + foreground: $text-strong; transition: background 100ms ease-out; } .sounds-editor-button:hover { - background: #45648E; - border: 1px #6D94C9; - foreground: #FFFFFF; + background: $accent-fill; + border: 1px $accent; + foreground: $white; } .sounds-editor-button-label { @@ -178,21 +172,21 @@ .sounds-editor-button-icon { size: 12px 12px; - tint: #D7DEEA; + tint: $text-strong; } .sounds-editor-button:hover .sounds-editor-button-icon { - tint: #FFFFFF; + tint: $white; } .sounds-editor-icon-button { size: 16px 16px; padding: 2px; - border-radius: 3px; - tint: #8B96A9; + border-radius: $radius-small; + tint: $text-muted; transition: tint 100ms ease-out; } .sounds-editor-icon-button:hover { - tint: #FF9D9D; + tint: $danger; } diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/theme.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/theme.hss new file mode 100644 index 000000000..d03777718 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/theme.hss @@ -0,0 +1,112 @@ +/* + * The engine's look in one place: every other stylesheet of the editor imports this one and takes + * its colors, sizes and fonts from these variables instead of writing its own literals. + * + * @import "hollowengine:ui/styles/theme.hss"; + * .panel { background: $bg-panel; } + * + * A stylesheet that declares a variable of the same name after the import retunes it for itself. + */ + +/* ---- surfaces, darkest to lightest ---- */ + +/* The window itself: toolbars, tab bars, splitters, the status bar. */ +$bg-window: #141518; +/* Tool windows and panels. */ +$bg-panel: #1C1E22; +/* Editors, fields, section headings: what sits on a panel. */ +$bg-field: #212328; +/* Popups, menus, dialogs and cards: what floats over the rest. */ +$bg-raised: #26282E; +/* Whatever the pointer is over. */ +$bg-hover: #2B2E35; +/* Pressed, or hovered on something already raised. */ +$bg-pressed: #353943; +/* A selected row, a toggled button. */ +$bg-selected: #2E3544; + +/* ---- lines ---- */ + +$divider: #2B2E35; +$border: #3A3E48; +/* A frame that has to be seen: a focused card, an emphasised outline. */ +$border-strong: #5F6677; + +/* ---- text, brightest to faintest ---- */ + +$text-strong: #E4E9F4; +$text: #C4CBDA; +$text-soft: #AEB8CA; +$text-muted: #8F9BAD; +$text-faint: #6A7384; +$white: #FFFFFF; +$caret: #F0F6FF; + +/* ---- accent and state ---- */ + +$accent: #6E9BDC; +$accent-fill: #3E5F8F; +$accent-fill-hover: #4A6FA5; +$accent-soft: alpha($accent, 0.2); +$selection: alpha(#5F8CCD, 0.46); +$row-selected: alpha(#587BAE, 0.32); + +$danger: #E07A7A; +$danger-soft: alpha(#C25757, 0.18); +$warning: #F7C363; +$success: #6FBF8A; + +$shadow: alpha(#000000, 0.45); +$scrim: alpha(#000000, 0.5); + +/* ---- sizes ---- */ + +/* How far a panel's content sits from its edge: headers, toolbars, lists and fields alike. */ +$panel-inset: 6px; + +/* + * The left rail of the window, the side stripe's column: the logo, the stripe buttons and the first + * icon of the status bar are all centered on it. + */ +$rail: 22px; + +$radius-small: 3px; +$radius: 4px; +$radius-large: 6px; + +$icon: 12px; +$icon-small: 10px; + +/* ---- text ---- */ + +$font: "ttf:hollowengine:fonts/jetbrains_mono_nl.ttf"; +$font-size: 9px; +$font-size-small: 8px; +$font-size-title: 11px; + +/* ---- scrollbars ---- */ + +$scroll-thumb: alpha(#9BA9C2, 0.28); +$scroll-thumb-hover: alpha(#AFBCD2, 0.6); + +/* ---- shared looks, taken in with `@apply $name;` ---- */ + +/* The scrollbar of every list and panel: thin, over the content, wider under the pointer. */ +$thin-scroll { + scrollbar: 4px; + scrollbar-margin: 2px; + scrollbar-overlay: true; + scrollbar-track: none; + scrollbar-thumb: $scroll-thumb; + scrollbar-thumb-hover: $scroll-thumb-hover; + scrollbar-hover-thickness: 6px; + scrollbar-thumb-radius: 4px; +} + +/* A text field of the editor: the code font, no vanilla drop shadow, the accent caret. */ +$field-text { + font-family: $font; + caret-color: $caret; + selection-color: $selection; + text-shadow: none; +} diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss new file mode 100644 index 000000000..7eb22ec41 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/timeline.hss @@ -0,0 +1,187 @@ +/* + * The timeline window: its bar, the track list and the ruler. Lanes, keys and curves are drawn in + * code, from the same palette (TimelineColors), because where they sit is data. + * + * Colors, sizes and the font come from theme.hss; TimelineColors mirrors the few the lanes need. + */ + +@import "hollowengine:ui/styles/theme.hss"; + + +/* Rounded like every other panel; the gap at the bottom keeps the square lanes out of the corners. */ +.timeline-root { + padding: 0px 0px 5px 0px; + background: $bg-panel; + border-radius: $radius-large; + font-family: $font; +} + +/* ---- bar ---- */ + +.timeline-toolbar { + size: 100% 22px; + padding: 0px $panel-inset; + gap: 1px; + align-items: start center; +} + +.timeline-button { + size: fit fit; + padding: 3px; + border-radius: $radius-small; + background: transparent; + hoverable: true; + clickable: true; + transition: background 110ms ease-out; +} + +.timeline-button:hover { background: $bg-hover; } + +.timeline-button.active { background: $bg-selected; } + +.timeline-button-icon { + size: 12px 12px; + tint: $text-soft; + transition: tint 110ms ease-out; +} + +.timeline-button:hover .timeline-button-icon { tint: $white; } + +.timeline-button.active .timeline-button-icon { tint: $accent; } + +.timeline-separator { + size: 1px 12px; + margin: 0px 4px; + background: $bg-hover; +} + +.timeline-time { + size: 52px fit; + min-height: 16px; + padding: 1px 4px; + background: $bg-field; + border-radius: $radius-small; + border: 1px $bg-field; + foreground: $text-strong; + font-size: $font-size; + text-align: right; + caret-color: $caret; + selection-color: $selection; + text-shadow: none; + transition: border 110ms ease-out; +} + +.timeline-time:hover { border: 1px $border; } + +.timeline-time:focus { border: 1px $accent; } + +.timeline-duration { + margin: 0px 4px 0px 3px; + foreground: $text-faint; + font-size: $font-size; + wrap: false; +} + + +/* The lanes and the track list scroll the way every IDE list does: a thin bar over the content. */ +.timeline-scroll { + @apply $thin-scroll; +} + +/* ---- track list ---- */ + +.timeline-corner { + background: $bg-panel; +} + +.timeline-corner-label { + margin: 0px 0px 0px 8px; + foreground: $text-faint; + font-size: $font-size-small; +} + +.timeline-row { + background: $bg-panel; + hoverable: true; + clickable: true; + transition: background 110ms ease-out; +} + +.timeline-row:hover { background: $bg-raised; } + +.timeline-row.group { background: $bg-field; } + +.timeline-row.group:hover { background: $bg-raised; } + +.timeline-row.channel { background: $bg-panel; } + +.timeline-row.focused { background: alpha($accent, 0.16); } + +.timeline-row.locked { background: $bg-window; } + +.timeline-row-label { + foreground: $text; + font-size: $font-size; + wrap: false; + text-overflow: dots; +} + +.timeline-row.group .timeline-row-label { foreground: $text-strong; } + +.timeline-row.channel .timeline-row-label { + foreground: $text-muted; + font-size: $font-size-small; +} + +.timeline-row.muted .timeline-row-label { foreground: $text-faint; } + +.timeline-row-toggle { + size: fit fit; + padding: 2px; + border-radius: $radius-small; + background: transparent; + hoverable: true; + clickable: true; + transition: background 110ms ease-out; +} + +.timeline-row-toggle:hover { background: $bg-hover; } + +.timeline-row-icon { + size: 10px 10px; + tint: $text-faint; + transition: tint 110ms ease-out; +} + +.timeline-row-toggle:hover .timeline-row-icon { tint: $white; } + +.timeline-row-toggle.on .timeline-row-icon { tint: $text-soft; } + +.timeline-row-toggle.warn .timeline-row-icon { tint: $accent; } + +/* Taking a track off the list is rare; the cross only shows on the row the pointer is on. */ +.timeline-row-toggle.hide-track { + opacity: 0; + transition: opacity 110ms ease-out, background 110ms ease-out; +} + +.timeline-row:hover .timeline-row-toggle.hide-track { opacity: 1; } + +.timeline-splitter { + background: $bg-window; + hoverable: true; + transition: background 110ms ease-out; +} + +.timeline-splitter:hover { background: $accent; } + +/* ---- ruler ---- */ + +.timeline-ruler { + background: $bg-panel; +} + +.timeline-ruler-label { + foreground: $text-faint; + font-size: $font-size-small; +} diff --git a/runtime/src/main/resources/assets/hollowengine/ui/styles/wait-screen.hss b/runtime/src/main/resources/assets/hollowengine/ui/styles/wait-screen.hss new file mode 100644 index 000000000..a1c46a849 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/wait-screen.hss @@ -0,0 +1,24 @@ +/* + * The screen that covers the game while a tool drives it. It is opaque on purpose: whatever the tool + * does to the camera underneath should not be seen. + */ + +@import "hollowengine:ui/styles/theme.hss"; + +.wait-screen { + size: 100% 100%; + gap: 6px; + align-items: center center; + background: $bg-window; + font-family: $font; +} + +.wait-heading { + font-size: $font-size-title; + foreground: $text-strong; +} + +.wait-message { + font-size: $font-size; + foreground: $text-muted; +} 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 d569fc423..53144d547 100644 --- a/runtime/src/main/resources/assets/hollowengine/ui/styles/widgets.hss +++ b/runtime/src/main/resources/assets/hollowengine/ui/styles/widgets.hss @@ -1,41 +1,48 @@ +@import "hollowengine:ui/styles/theme.hss"; + .dropdown-button { height: 24px; padding: 0px 8px; gap: 6px; - foreground: #cbd3df; - border-radius: 4px; + foreground: $text; + border-radius: $radius; transition: background 100ms ease-out, foreground 100ms ease-out; } .dropdown-button:hover { - background: rgba(255, 255, 255, 0.08); - foreground: #ffffff; + background: alpha($white, 0.08); + foreground: $white; } .dropdown-button[expanded="true"] { - background: rgba(255, 255, 255, 0.10); - foreground: #ffffff; + background: alpha($white, 0.1); + foreground: $white; } .dropdown-button[expanded="true"] .dropdown-button-arrow { rotate: 0px 0px 180px; - fill: #ffffff; + stroke: $white; } .dropdown-button-icon, -.dropdown-item-icon, +.dropdown-item-icon { + size: 14px 14px; + tint: $white; +} + +/* A row icon a little under the height of the row's text line, so folders do not crowd the names. */ .tree-icon { - size: 16px 16px; - tint: #ffffff; + size: 13px 13px; + tint: $white; } .dropdown-item-check { - size: 14px 14px; - checkbox-active: #5f8ccd; - checkbox-mark: #ffffff; - border: 1px #647084; + size: 10px 10px; + checkbox-active: $accent; + checkbox-mark: $white; + border: 1px $border; } /* Fills the free space of a button wider than its text, so the arrow sits flush against the right edge. */ @@ -44,33 +51,36 @@ wrap: false; } +/* The tree's chevron turned to point down, so every disclosure in the editor is the same two strokes. */ .dropdown-button-arrow { - size: 7px 5px; - shape: path("M 0 0 L 7 0 L 3.5 5 Z", 7 5); - fill: #9aa5b7; + size: 10px 10px; + shape: path("M 2 4 L 6 8 L 10 4", 12 12); + fill: none; + stroke: $text-muted; + stroke-width: 1.5px; transition: rotate 120ms ease-out, - fill 120ms ease-out; + stroke 120ms ease-out; } .dropdown-popup { - min-width: 156px; + min-width: 140px; padding: 3px; - gap: 2px; + gap: 1px; align-items: stretch; - background: #24272E; - border: 1px #5F6677; - border-radius: 5px; - shadow: 1px 1px 3px 0px rgba(0, 0, 0, 0.42); + background: $bg-raised; + border: 1px $border; + border-radius: $radius-large; + shadow: 0px 6px 16px 0px $shadow; } .dropdown-item { width: 100%; - gap: 4px; - padding-vertical: 3px; + gap: 6px; + padding-vertical: 2px; padding-horizontal: 8px; - border-radius: 4px; - background: #31343D00; + border-radius: $radius; + background: transparent; transition: background 80ms ease-out; } @@ -84,31 +94,21 @@ align: end center; margin-left: 18px; font-size: 10px; - foreground: #7f8a9d; + foreground: $text-muted; } .dropdown-separator { size: fill 1px; - margin: 2px 4px; - background: #3a3f4b; + margin: 2px 6px; + background: $bg-pressed; } .dropdown-item:hover { - background: #31343D; + background: alpha($accent, 0.18); } .dropdown-item.open { - background: #31343D; -} - -.timeline-scroll { - scrollbar-overlay: true; - scrollbar-thickness: 5px; - scrollbar-margin: 3px; - scrollbar-track: #00000000; - scrollbar-thumb: #A8B0BF66; - scrollbar-track-radius: 2.5; - scrollbar-thumb-radius: 2.5; + background: alpha($accent, 0.18); } .dropdown-item.disabled { @@ -120,10 +120,7 @@ } .tree-view-scroll { - scrollbar: 6px; - scrollbar-track: rgba(255, 255, 255, 0.03); - scrollbar-thumb: rgba(155, 169, 194, 0.38); - scrollbar-thumb-radius: 3px; + @apply $thin-scroll; } .tree-filter-row { @@ -133,57 +130,82 @@ padding: 1px 4px; gap: 3px; align-items: start center; - background: #31343D; - border-radius: 4px; + background: $bg-field; + border-radius: $radius; } .tree-filter-icon, .tree-filter-close { size: 12px 12px; - tint: #aeb8ca; + tint: $text-soft; } .tree-filter-input { min-height: 18px; padding: 2px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); + caret-color: $caret; + selection-color: $selection; } .tree-filter-close:hover { - tint: #ffffff; + tint: $white; } .tree-item { padding-right: 6px; - background: #00000000; - foreground: #aeb8ca; - border-radius: 4px; + background: transparent; + foreground: $text-soft; + border-radius: $radius-small; transition: background 200ms ease-out, foreground 200ms ease-out; + animations: tree-row-in 110ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes tree-row-in { + from { opacity: 0; translate: 0 -4 0; } + to { opacity: 1; translate: 0 0 0; } } .tree-item:hover { - background: rgba(255, 255, 255, 0.07); - foreground: #ffffff; + background: $bg-hover; + foreground: $white; } .tree-item.selected { - background: rgba(88, 123, 174, 0.32); - foreground: #ffffff; + background: $row-selected; + foreground: $white; } .tree-item.drop-target { - background: rgba(88, 123, 174, 0.55); - foreground: #ffffff; + background: alpha($accent, 0.55); + foreground: $white; } .tree-item.drop-rejected { - background: rgba(190, 60, 60, 0.35); + background: alpha($danger, 0.35); } .tree-indent { - size: 14px 100%; + size: 16px 100%; + fill: none; + stroke: $divider; + stroke-width: 1px; +} + +.tree-indent.line { + shape: path("M 7.5 0 L 7.5 20", 16 20); +} + +.tree-indent.end { + shape: path("M 7.5 0 L 7.5 10", 16 20); +} + +.tree-indent.branch { + shape: path("M 7.5 0 L 7.5 20 M 7.5 6 Q 7.5 10 11.5 10 L 15 10", 16 20); +} + +.tree-indent.last { + shape: path("M 7.5 0 L 7.5 6 Q 7.5 10 11.5 10 L 15 10", 16 20); } .tree-expander { @@ -191,7 +213,7 @@ margin: 0px 2px; shape: path("M 3 2 L 8 6 L 3 10", 12 12); fill: none; - stroke: #7f8a9d; + stroke: $text-muted; stroke-width: 1.5px; transition: rotate 120ms ease-out, @@ -225,21 +247,15 @@ .code-editor { padding: 8px; - caret-color: #f0f6ff; - selection-color: rgba(95, 140, 205, 0.46); - text-shadow: 0, 1, 2, rgba(255, 255, 255, 0.4); + caret-color: $caret; + selection-color: $selection; line-numbers: true; - line-number-color: #556070; - font-family: hollowengine:fonts/monocraft; - font-size: 10px; - scrollbar: 7px; - scrollbar-track: rgba(255, 255, 255, 0.03); - scrollbar-thumb: rgba(155, 169, 194, 0.42); - scrollbar-thumb-radius: 3px; + line-number-color: $text-faint; + @apply $thin-scroll; } .editable-text-field-gutter { - background: #24272E; + background: $bg-field; } .editable-text-field-inlay { @@ -247,26 +263,24 @@ padding: 0px 1px; gap: 3px; align-items: start center; - background: #1E1F22; + background: $bg-panel; } .editable-text-field-inlay-text { - /* A share of the field's font, so hints follow whatever size the text is drawn at. */ font-size: 70%; - text-effects: glow(12px, #FFFFFF); - foreground: #687588; + foreground: $text-faint; text-wrap: false; - padding: 2px; + padding: 0px 2px; } .editable-text-field-inlay-icon { size: 12px 12px; align-y: center; - tint: #687588; + tint: $text-faint; } .overlay-scrim { - background: rgba(8, 9, 12, 0.46); + background: alpha(#000000, 0.46); backdrop-filter: blur(8px); animations: overlay-scrim-in 140ms ease-out both; } @@ -278,43 +292,42 @@ .ui-tooltip { max-width: 260px; - padding: 3px 6px; - background: #1E2127; - border: 1px #5F6677; - border-radius: 3px; - shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.6); + padding: 3px 7px; + background: $bg-raised; + border: 1px $border; + border-radius: $radius; + shadow: 0px 4px 12px 0px $shadow; } .ui-tooltip-label { - font-size: 10px; - foreground: #d7deea; + foreground: $text-strong; } .ide-quick-fix-popup { padding: 3px; gap: 1px; - background: #24272E; - border: 1px #5F6677; - border-radius: 4px; - shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.76); + background: $bg-raised; + border: 1px $border; + border-radius: $radius-large; + shadow: 0px 6px 16px 0px $shadow; } .ide-quick-fix-row { width: 100%; height: fit; min-height: 18px; - padding: 2px 7px; - border-radius: 3px; - foreground: #d7deea; + padding: 2px 8px; + border-radius: $radius; + foreground: $text-strong; text-wrap: false; } .ide-quick-fix-row.selected { - background: rgba(67, 106, 154, 0.62); + background: alpha($accent, 0.24); } .ide-quick-fix-row:hover { - background: rgba(84, 124, 176, 0.72); + background: alpha($accent, 0.18); } .ide-quick-fix-label { @@ -327,29 +340,44 @@ } .ide-completion-popup { - background: #24272E; - border: 1px #5F6677; - border-radius: 4px; - shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.76); + background: $bg-raised; + border: 1px $border; + border-radius: $radius-large; + shadow: 0px 6px 16px 0px $shadow; clip: true; } +.ide-completion-divider { + background: $bg-hover; +} + .ide-completion-list { size: 100% 100%; text-wrap: false; - scrollbar: 5px; - scrollbar-track: none; - scrollbar-thumb: #5F6677; + @apply $thin-scroll; } .ide-completion-hint { + width: 100%; + padding: 0px 8px; + gap: 5px; align-items: start center; - padding: 2px; + font-size: 85%; + foreground: $text-faint; +} + +.ide-completion-hint-icon { + size: 10px 10px; + tint: $text-faint; +} + +.ide-completion-hint-key { + foreground: $text-soft; } .ide-completion-row { width: 100%; - padding: 0px 7px; + padding: 0px 8px; gap: 6px; align-items: start center; text-wrap: false; @@ -357,24 +385,24 @@ } .ide-completion-row:hover { - background: rgba(67, 92, 132, 0.34); + background: alpha($white, 0.05); } .ide-completion-row.selected { - background: rgba(67, 106, 154, 0.62); + background: alpha($accent, 0.24); } .ide-completion-row.selected:hover { - background: rgba(84, 124, 176, 0.72); + background: alpha($accent, 0.3); } .ide-completion-icon { - size: 14px 14px; - tint: rgba(160, 188, 230, 0.96); + size: 12px 12px; + tint: alpha($text-soft, 0.9); } .ide-completion-item { - size: 16px 16px; + size: 14px 14px; } .ide-completion-main { @@ -389,43 +417,71 @@ } .ide-completion-label { - foreground: #eef4ff; + foreground: $text-strong; text-wrap: false; } .ide-completion-detail { - foreground: rgba(169, 181, 198, 0.88); + foreground: alpha($text-soft, 0.88); text-wrap: false; } .ide-completion-tail { - foreground: rgba(140, 153, 174, 0.72); - font-size: 10px; + foreground: $text-faint; text-wrap: false; align: end center; } +.ide-code-insight-popup { + padding: 6px 8px; + background: $bg-raised; + border: 1px $border; + border-radius: $radius-large; + shadow: 0px 6px 16px 0px $shadow; +} + +.ide-active-parameter { + background: alpha($accent, 0.24); + border-radius: 2px; +} + +.ide-hover-documentation { + foreground: $text-soft; +} + .ide-diagnostic-tooltip { padding: 5px 8px; align-items: start center; - background: #31343D; - border: 1px #5F6677; - border-radius: 4px; - shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.56); + background: $bg-raised; + border: 1px $border; + border-radius: $radius-large; + shadow: 0px 6px 16px 0px $shadow; clip: false; } +.ide-diagnostic-tooltip.error { + border: 1px alpha($danger, 0.55); +} + +.ide-diagnostic-tooltip.warning { + border: 1px alpha($warning, 0.5); +} + +.ide-diagnostic-tooltip.info { + border: 1px alpha($accent, 0.5); +} + +/* Its size is the editor's, set in code. */ .ide-diagnostic-tooltip-message { - foreground: #edf3ff; - font-size: 11px; + foreground: $text-strong; text-wrap: true; } .timeline-keyframe-visual { scale: 1; - stroke: #17191f; + stroke: $bg-window; stroke-width: 1px; - shadow: 0px 0px 2px 0px rgba(130, 164, 220, 0.28); + shadow: 0px 0px 2px 0px alpha($accent, 0.28); transition: scale 160ms ease-out, stroke 160ms ease-out, @@ -434,12 +490,12 @@ .timeline-keyframe:hover .timeline-keyframe-visual { scale: 1.12; - stroke: #dbe5f5; - shadow: 0px 0px 3px 0px rgba(145, 183, 242, 0.48); + stroke: $text-strong; + shadow: 0px 0px 3px 0px alpha($accent, 0.48); } .timeline-keyframe.selected .timeline-keyframe-visual { scale: 1.12; - stroke: #ffffff; - shadow: 0px 0px 4px 0px rgba(165, 201, 255, 0.72); + stroke: $white; + shadow: 0px 0px 4px 0px alpha($accent, 0.72); } diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/example.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/example.vfx new file mode 100644 index 000000000..df322f99d --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/example.vfx @@ -0,0 +1,283 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/emitter", + "id": "flame", + "name": "Flame", + "transform": { + "position": [ + 0.0, + 0.1, + 0.0 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "flame-plane", + "material": { + "blend": "ADDITIVE" + }, + "particle": { + "size": { + "x": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.13 + }, + { + "time": 0.25, + "value": 0.22 + }, + { + "time": 1.0 + } + ] + } + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "g": 0.78, + "b": 0.3 + } + }, + { + "position": 0.45, + "color": { + "g": 0.43, + "b": 0.035, + "a": 0.85 + } + }, + { + "position": 1.0, + "color": { + "r": 0.35, + "g": 0.08, + "b": 0.02, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const", + "value": 45.0 + }, + "duration": 0.0, + "maxParticles": 160 + }, + "shape": { + "kind": "CONE", + "radius": { + "type": "const", + "value": 0.08 + }, + "angle": { + "type": "const", + "value": 14.0 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 0.35, + "to": 0.75 + }, + "speed": { + "type": "range", + "from": 0.8, + "to": 1.6 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": -1.2 + }, + "drag": { + "type": "const", + "value": 0.8 + } + }, + "modules": [ + { + "type": "hollowengine:vfx/force", + "id": "updraft", + "kind": "VORTEX", + "strength": { + "type": "const", + "value": 1.5 + }, + "radius": { + "type": "const", + "value": 1.5 + } + }, + { + "type": "hollowengine:vfx/noise", + "strength": { + "type": "const", + "value": 0.7 + }, + "frequency": { + "type": "const", + "value": 1.5 + }, + "scrollSpeed": 0.8 + } + ] + }, + { + "type": "hollowengine:vfx/emitter", + "id": "smoke", + "name": "Smoke", + "transform": { + "position": [ + 0.0, + 0.45, + 0.0 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "smoke-plane", + "material": { + "lighting": "WORLD" + }, + "particle": { + "size": { + "x": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.18 + }, + { + "time": 1.0, + "value": 0.56 + } + ] + } + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "rotation": { + "z": { + "type": "expr", + "source": "p.random * 360 + p.age * 20" + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.32, + "g": 0.3, + "b": 0.29, + "a": 0.55 + } + }, + { + "position": 1.0, + "color": { + "r": 0.32, + "g": 0.3, + "b": 0.29, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const", + "value": 7.0 + }, + "duration": 0.0, + "maxParticles": 48 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.06 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.2, + "to": 2.2 + }, + "speed": { + "type": "range", + "from": 0.2, + "to": 0.5 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": -0.4 + }, + "drag": { + "type": "const", + "value": 0.4 + } + }, + "modules": [ + { + "type": "hollowengine:vfx/noise", + "strength": { + "type": "const", + "value": 0.35 + }, + "frequency": { + "type": "const", + "value": 0.6 + }, + "scrollSpeed": 0.3 + } + ] + } + ], + "timeline": { + "duration": 4.0 + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/explosion.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/explosion.vfx new file mode 100644 index 000000000..56357a69f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/explosion.vfx @@ -0,0 +1,766 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/group", + "id": "explosion", + "name": "Explosion", + "transform": { + "position": [ + 0.0, + 0.5, + 0.0 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/group", + "id": "core", + "name": "Core", + "children": [ + { + "type": "hollowengine:vfx/emitter", + "id": "fire", + "name": "Fire", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "fire-plane", + "material": { + "blend": "ADDITIVE" + }, + "particle": { + "size": { + "x": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.4, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "value": 1.1, + "interpolation": "LINEAR" + } + ] + } + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "rotation": { + "z": { + "type": "expr", + "source": "p.random * 360" + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "g": 0.95, + "b": 0.7 + } + }, + { + "position": 0.3, + "color": { + "g": 0.45, + "b": 0.1, + "a": 0.9 + } + }, + { + "position": 1.0, + "color": { + "r": 0.3, + "g": 0.05, + "b": 0.02, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const" + }, + "burst": 60, + "duration": 0.1, + "loopDelay": 2.9, + "maxParticles": 128 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.3 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 0.5 + }, + "speed": { + "type": "range", + "from": 1.5, + "to": 3.5 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": -0.8 + }, + "drag": { + "type": "const", + "value": 2.5 + } + } + }, + { + "type": "hollowengine:vfx/emitter", + "id": "sparks", + "name": "Sparks", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "sparks-plane", + "transform": { + "scale": [ + 1.0, + 4.0, + 1.0 + ] + }, + "material": { + "blend": "ADDITIVE" + }, + "facing": "VELOCITY", + "particle": { + "size": { + "x": { + "type": "const", + "value": 0.06 + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "g": 0.9, + "b": 0.5 + } + }, + { + "position": 1.0, + "color": { + "g": 0.4, + "b": 0.1, + "a": 0.0 + } + } + ] + } + } + } + }, + { + "type": "hollowengine:vfx/trail", + "id": "sparks-trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "g": 0.7, + "b": 0.3, + "a": 0.8 + } + }, + { + "position": 1.0, + "color": { + "g": 0.3, + "b": 0.05, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.04, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.25, + "maxPoints": 12, + "particle": { + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "g": 0.9, + "b": 0.5 + } + }, + { + "position": 1.0, + "color": { + "g": 0.4, + "b": 0.1, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const" + }, + "burst": 80, + "duration": 0.1, + "loopDelay": 2.9, + "maxParticles": 128 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.1 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 0.6, + "to": 1.4 + }, + "speed": { + "type": "range", + "from": 5.0, + "to": 10.0 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": 9.0 + }, + "drag": { + "type": "const", + "value": 0.6 + } + }, + "modules": [ + { + "type": "hollowengine:vfx/collision", + "bounce": 0.35 + } + ] + }, + { + "type": "hollowengine:vfx/sphere", + "id": "flash", + "name": "Flash", + "transform": { + "scale": [ + 0.1, + 0.1, + 0.1 + ] + }, + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "g": 0.8, + "b": 0.4 + } + } + } + ] + }, + { + "type": "hollowengine:vfx/emitter", + "id": "smoke", + "name": "Smoke", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "smoke-plane", + "material": { + "lighting": "WORLD" + }, + "particle": { + "size": { + "x": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.6, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "value": 1.8, + "interpolation": "LINEAR" + } + ] + } + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "rotation": { + "z": { + "type": "expr", + "source": "p.random * 360" + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.25, + "g": 0.23, + "b": 0.22, + "a": 0.0 + } + }, + { + "position": 0.1, + "color": { + "r": 0.25, + "g": 0.23, + "b": 0.22, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.3, + "g": 0.3, + "b": 0.3, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const" + }, + "burst": 25, + "duration": 0.1, + "loopDelay": 2.9, + "maxParticles": 64 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.6 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.5, + "to": 2.5 + }, + "speed": { + "type": "range", + "from": 0.3 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": -0.3 + }, + "drag": { + "type": "const", + "value": 1.0 + } + }, + "modules": [ + { + "type": "hollowengine:vfx/noise", + "strength": { + "type": "const", + "value": 0.6 + } + } + ] + }, + { + "type": "hollowengine:vfx/cylinder", + "id": "shockwave", + "name": "Shockwave", + "transform": { + "position": [ + 0.0, + -0.45, + 0.0 + ], + "scale": [ + 0.2, + 0.05, + 0.2 + ] + }, + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.9, + "g": 0.85, + "a": 0.8 + } + }, + "segments": 48, + "caps": false + }, + { + "type": "hollowengine:vfx/post_effect", + "id": "impact", + "name": "Impact", + "shader": "hollowengine:vfx/post/impact", + "uniforms": [ + { + "name": "Strength" + }, + { + "name": "Threshold", + "value": { + "type": "float", + "value": { + "type": "const", + "value": 0.35 + } + } + }, + { + "name": "Invert" + } + ] + }, + { + "type": "hollowengine:vfx/camera_shake", + "id": "shake", + "strength": { + "type": "expr", + "source": "max(0, 1 - p.age * 1.6) * clamp(1 - n.camera_distance / 32, 0, 1) * 1.5" + } + } + ] + } + ], + "timeline": { + "duration": 3.0, + "tracks": [ + { + "node": "flash", + "property": "transform.scale", + "curves": [ + { + "keys": [ + { + "value": 0.1, + "interpolation": "LINEAR" + }, + { + "time": 0.12, + "value": 2.2, + "interpolation": "LINEAR" + }, + { + "time": 0.4, + "value": 2.6, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 1, + "keys": [ + { + "value": 0.1, + "interpolation": "LINEAR" + }, + { + "time": 0.12, + "value": 2.2, + "interpolation": "LINEAR" + }, + { + "time": 0.4, + "value": 2.6, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 2, + "keys": [ + { + "value": 0.1, + "interpolation": "LINEAR" + }, + { + "time": 0.12, + "value": 2.2, + "interpolation": "LINEAR" + }, + { + "time": 0.4, + "value": 2.6, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "flash", + "property": "tint", + "curves": [ + { + "channel": 3, + "keys": [ + { + "value": 1.0, + "interpolation": "LINEAR" + }, + { + "time": 0.12, + "value": 0.9, + "interpolation": "LINEAR" + }, + { + "time": 0.45, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "shockwave", + "property": "transform.scale", + "curves": [ + { + "keys": [ + { + "value": 0.2, + "interpolation": "LINEAR" + }, + { + "time": 0.5, + "value": 6.0, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 1, + "keys": [ + { + "value": 0.05, + "interpolation": "LINEAR" + }, + { + "time": 0.5, + "value": 0.02, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 2, + "keys": [ + { + "value": 0.2, + "interpolation": "LINEAR" + }, + { + "time": 0.5, + "value": 6.0, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "shockwave", + "property": "tint", + "curves": [ + { + "channel": 3, + "keys": [ + { + "value": 0.8, + "interpolation": "LINEAR" + }, + { + "time": 0.5, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "impact", + "property": "uniforms.Strength", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.08, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "impact", + "property": "uniforms.Invert", + "curves": [ + { + "keys": [ + { + "interpolation": "CONSTANT" + }, + { + "time": 0.04, + "value": 1.0, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "impact", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.08, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "flash", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.45, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "shockwave", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.5, + "interpolation": "CONSTANT" + } + ] + } + ] + } + ] + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/fireworks.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/fireworks.vfx new file mode 100644 index 000000000..be85c8dc7 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/fireworks.vfx @@ -0,0 +1,354 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/emitter", + "id": "rockets", + "name": "Rockets", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "rocket-head", + "name": "Head", + "material": { + "blend": "ADDITIVE", + "glow": 3.0 + }, + "particle": { + "color": { + "type": "channels", + "r": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.random)" + }, + "g": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.random - 120)" + }, + "b": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.random - 240)" + }, + "a": { + "type": "expr", + "source": "1" + } + } + } + }, + { + "type": "hollowengine:vfx/trail", + "id": "rocket-trail", + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE", + "glow": 1.5 + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.08 + }, + { + "time": 1.0 + } + ] + } + }, + "lifetime": 0.35, + "particle": { + "color": { + "type": "channels", + "r": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.random)" + }, + "g": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.random - 120)" + }, + "b": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.random - 240)" + }, + "a": { + "type": "expr", + "source": "1" + } + } + } + }, + { + "type": "hollowengine:vfx/emitter", + "id": "burst", + "name": "Burst", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "spark", + "name": "Spark", + "material": { + "blend": "ADDITIVE", + "glow": 2.5 + }, + "particle": { + "size": { + "x": { + "type": "const", + "value": 0.14 + }, + "y": { + "type": "const", + "value": 0.14 + }, + "z": { + "type": "const", + "value": 0.14 + } + }, + "color": { + "type": "channels", + "r": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.parent_random)" + }, + "g": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.parent_random - 120)" + }, + "b": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.parent_random - 240)" + }, + "a": { + "type": "expr", + "source": "1 - p.progress * p.progress" + } + } + } + }, + { + "type": "hollowengine:vfx/trail", + "id": "spark-trail", + "name": "Spark Trail", + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE", + "glow": 1.0 + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.05 + }, + { + "time": 1.0 + } + ] + } + }, + "lifetime": 0.25, + "maxPoints": 12, + "particle": { + "color": { + "type": "channels", + "r": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.parent_random)" + }, + "g": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.parent_random - 120)" + }, + "b": { + "type": "expr", + "source": "0.5 + 0.5 * cos(360 * p.parent_random - 240)" + }, + "a": { + "type": "expr", + "source": "1 - p.progress" + } + } + } + } + ], + "emission": { + "maxParticles": 2000 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.1 + }, + "thickness": { + "type": "const" + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.2, + "to": 1.9 + }, + "speed": { + "type": "range", + "from": 6.0, + "to": 9.0 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": 2.5 + }, + "drag": { + "type": "const", + "value": 1.3 + } + }, + "subEmission": { + "count": { + "type": "const", + "value": 110.0 + }, + "inheritVelocity": { + "type": "const", + "value": 0.15 + } + } + }, + { + "type": "hollowengine:vfx/emitter", + "id": "launch-smoke", + "name": "Launch Smoke", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "smoke", + "name": "Smoke", + "material": { + "softness": 0.8 + }, + "particle": { + "size": { + "x": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.6 + }, + { + "time": 1.0, + "value": 1.8 + } + ] + } + } + }, + "color": { + "type": "channels", + "r": { + "type": "const", + "value": 0.55 + }, + "g": { + "type": "const", + "value": 0.55 + }, + "b": { + "type": "const", + "value": 0.6 + }, + "a": { + "type": "expr", + "source": "0.35 * (1 - p.progress)" + } + } + } + } + ], + "emission": { + "maxParticles": 128 + }, + "shape": { + "kind": "DISC", + "radius": { + "type": "const", + "value": 0.4 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.5, + "to": 2.5 + }, + "speed": { + "type": "range", + "from": 0.3, + "to": 0.8 + } + }, + "motion": { + "drag": { + "type": "const", + "value": 0.6 + } + }, + "subEmission": { + "event": "SPAWN", + "count": { + "type": "const", + "value": 6.0 + } + } + } + ], + "emission": { + "rate": { + "type": "const", + "value": 1.2 + }, + "duration": 0.0, + "maxParticles": 16 + }, + "shape": { + "kind": "CONE", + "radius": { + "type": "const", + "value": 0.3 + }, + "angle": { + "type": "const", + "value": 10.0 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.1, + "to": 1.5 + }, + "speed": { + "type": "range", + "from": 15.0, + "to": 19.0 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": 6.0 + } + } + } + ], + "timeline": { + "duration": 6.0 + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/lightning.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/lightning.vfx new file mode 100644 index 000000000..7367e346f --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/lightning.vfx @@ -0,0 +1,540 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/group", + "id": "source", + "name": "Source", + "transform": { + "position": [ + 0.0, + 3.0, + 0.0 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/emitter", + "id": "cloud", + "name": "Cloud", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "cloud-plane", + "particle": { + "size": { + "x": { + "type": "range", + "from": 0.8, + "to": 1.4 + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "rotation": { + "z": { + "type": "expr", + "source": "p.random * 360" + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.15, + "g": 0.15, + "b": 0.2, + "a": 0.0 + } + }, + { + "position": 0.3, + "color": { + "r": 0.15, + "g": 0.15, + "b": 0.2, + "a": 0.6 + } + }, + { + "position": 1.0, + "color": { + "r": 0.15, + "g": 0.15, + "b": 0.2, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const", + "value": 12.0 + }, + "duration": 0.0, + "maxParticles": 48 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.6 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.5, + "to": 2.5 + }, + "speed": { + "type": "range", + "from": 0.05, + "to": 0.2 + } + } + } + ] + }, + { + "type": "hollowengine:vfx/beam", + "id": "bolt", + "name": "Bolt", + "tint": { + "type": "solid", + "color": { + "r": 0.75, + "g": 0.85 + } + }, + "target": "source", + "width": { + "type": "const", + "value": 0.12 + }, + "segments": 40, + "noise": { + "type": "const", + "value": 0.5 + }, + "noiseFrequency": 2.5, + "noiseSpeed": 12.0 + }, + { + "type": "hollowengine:vfx/beam", + "id": "branch", + "name": "Branch", + "transform": { + "position": [ + 0.0, + 1.5, + 0.0 + ] + }, + "tint": { + "type": "solid", + "color": { + "r": 0.75, + "g": 0.85 + } + }, + "end": [ + 0.9, + -1.2, + 0.4 + ], + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.06, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "value": 0.01, + "interpolation": "LINEAR" + } + ] + } + }, + "segments": 20, + "noise": { + "type": "const", + "value": 0.3 + }, + "noiseFrequency": 3.0, + "noiseSpeed": 12.0 + }, + { + "type": "hollowengine:vfx/emitter", + "id": "sparks", + "name": "Sparks", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "sparks-plane", + "transform": { + "scale": [ + 1.0, + 3.0, + 1.0 + ] + }, + "material": { + "blend": "ADDITIVE" + }, + "facing": "VELOCITY", + "particle": { + "size": { + "x": { + "type": "const", + "value": 0.05 + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.75, + "g": 0.85 + } + }, + { + "position": 1.0, + "color": { + "r": 0.75, + "g": 0.85, + "a": 0.0 + } + } + ] + } + } + } + }, + { + "type": "hollowengine:vfx/trail", + "id": "sparks-trail", + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.03, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.2, + "maxPoints": 10, + "particle": { + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.75, + "g": 0.85 + } + }, + { + "position": 1.0, + "color": { + "r": 0.75, + "g": 0.85, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const" + }, + "burst": 40, + "duration": 0.1, + "loopDelay": 1.9, + "maxParticles": 64 + }, + "shape": { + "kind": "SPHERE", + "radius": { + "type": "const", + "value": 0.05 + } + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 0.4, + "to": 0.9 + }, + "speed": { + "type": "range", + "from": 2.0, + "to": 5.0 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": 8.0 + } + }, + "modules": [ + { + "type": "hollowengine:vfx/collision", + "bounce": 0.3 + } + ] + }, + { + "type": "hollowengine:vfx/sphere", + "id": "glow", + "name": "Ground Glow", + "transform": { + "scale": [ + 0.1, + 0.1, + 0.1 + ] + }, + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.75, + "g": 0.85 + } + } + }, + { + "type": "hollowengine:vfx/post_effect", + "id": "aberration", + "name": "Aberration", + "shader": "hollowengine:vfx/post/chromatic_aberration", + "uniforms": [ + { + "name": "Strength" + } + ] + }, + { + "type": "hollowengine:vfx/camera_shake", + "id": "shake", + "strength": { + "type": "expr", + "source": "max(0, 1 - p.age * 3) * clamp(1 - n.camera_distance / 24, 0, 1) * 2" + }, + "frequency": { + "type": "const", + "value": 20.0 + }, + "amplitude": [ + 1.2, + 1.2, + 1.5 + ] + } + ], + "timeline": { + "duration": 2.0, + "tracks": [ + { + "node": "bolt", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.12, + "interpolation": "CONSTANT" + }, + { + "time": 0.2, + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.3, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "branch", + "curves": [ + { + "keys": [ + { + "interpolation": "CONSTANT" + }, + { + "time": 0.2, + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.3, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "glow", + "property": "transform.scale", + "curves": [ + { + "keys": [ + { + "value": 0.1, + "interpolation": "LINEAR" + }, + { + "time": 0.05, + "value": 1.2, + "interpolation": "LINEAR" + }, + { + "time": 0.35, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 1, + "keys": [ + { + "value": 0.1, + "interpolation": "LINEAR" + }, + { + "time": 0.05, + "value": 1.2, + "interpolation": "LINEAR" + }, + { + "time": 0.35, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 2, + "keys": [ + { + "value": 0.1, + "interpolation": "LINEAR" + }, + { + "time": 0.05, + "value": 1.2, + "interpolation": "LINEAR" + }, + { + "time": 0.35, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "aberration", + "property": "uniforms.Strength", + "curves": [ + { + "keys": [ + { + "value": 3.0, + "interpolation": "LINEAR" + }, + { + "time": 0.3, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "aberration", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.3, + "interpolation": "CONSTANT" + } + ] + } + ] + }, + { + "node": "glow", + "curves": [ + { + "keys": [ + { + "value": 1.0, + "interpolation": "CONSTANT" + }, + { + "time": 0.35, + "interpolation": "CONSTANT" + } + ] + } + ] + } + ] + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/magic_shield.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/magic_shield.vfx new file mode 100644 index 000000000..0e8980df5 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/magic_shield.vfx @@ -0,0 +1,767 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/group", + "id": "circle", + "name": "Magic Circle", + "transform": { + "position": [ + 0.0, + 0.02, + 0.0 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "glow", + "name": "Glow", + "transform": { + "rotation": [ + 90.0, + 0.0, + 0.0 + ], + "scale": [ + 3.2, + 3.2, + 1.0 + ] + }, + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "channels", + "r": { + "type": "const", + "value": 0.3 + }, + "g": { + "type": "const", + "value": 0.6 + }, + "a": { + "type": "expr", + "source": "0.35 + 0.15 * math.sin(e.time * 180)" + } + }, + "facing": "NONE" + }, + { + "type": "hollowengine:vfx/group", + "id": "orbit", + "name": "Orbit", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "rune_0", + "name": "Rune 1", + "transform": { + "position": [ + 1.3, + 0.1, + 0.0 + ], + "scale": [ + 0.35, + 0.35, + 0.35 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/trail", + "id": "rune_0_trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.12, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.6 + } + ], + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.4, + "g": 0.75 + } + } + }, + { + "type": "hollowengine:vfx/plane", + "id": "rune_1", + "name": "Rune 2", + "transform": { + "position": [ + 0.65, + 0.1, + 1.125833 + ], + "scale": [ + 0.35, + 0.35, + 0.35 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/trail", + "id": "rune_1_trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.12, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.6 + } + ], + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.4, + "g": 0.75 + } + } + }, + { + "type": "hollowengine:vfx/plane", + "id": "rune_2", + "name": "Rune 3", + "transform": { + "position": [ + -0.65, + 0.1, + 1.125833 + ], + "scale": [ + 0.35, + 0.35, + 0.35 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/trail", + "id": "rune_2_trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.12, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.6 + } + ], + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.4, + "g": 0.75 + } + } + }, + { + "type": "hollowengine:vfx/plane", + "id": "rune_3", + "name": "Rune 4", + "transform": { + "position": [ + -1.3, + 0.1, + 1.5920408E-16 + ], + "scale": [ + 0.35, + 0.35, + 0.35 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/trail", + "id": "rune_3_trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.12, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.6 + } + ], + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.4, + "g": 0.75 + } + } + }, + { + "type": "hollowengine:vfx/plane", + "id": "rune_4", + "name": "Rune 5", + "transform": { + "position": [ + -0.65, + 0.1, + -1.125833 + ], + "scale": [ + 0.35, + 0.35, + 0.35 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/trail", + "id": "rune_4_trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.12, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.6 + } + ], + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.4, + "g": 0.75 + } + } + }, + { + "type": "hollowengine:vfx/plane", + "id": "rune_5", + "name": "Rune 6", + "transform": { + "position": [ + 0.65, + 0.1, + -1.125833 + ], + "scale": [ + 0.35, + 0.35, + 0.35 + ] + }, + "children": [ + { + "type": "hollowengine:vfx/trail", + "id": "rune_5_trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.7 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.12, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.6 + } + ], + "material": { + "blend": "ADDITIVE" + }, + "tint": { + "type": "solid", + "color": { + "r": 0.4, + "g": 0.75 + } + } + } + ] + }, + { + "type": "hollowengine:vfx/emitter", + "id": "motes", + "name": "Motes", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "motes-plane", + "material": { + "blend": "ADDITIVE" + }, + "particle": { + "size": { + "x": { + "type": "const", + "value": 0.08 + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + }, + { + "position": 0.2, + "color": { + "r": 0.4, + "g": 0.75 + } + }, + { + "position": 1.0, + "color": { + "r": 0.4, + "g": 0.75, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const", + "value": 25.0 + }, + "duration": 0.0, + "maxParticles": 128 + }, + "space": "LOCAL", + "shape": { + "kind": "DISC", + "radius": { + "type": "const", + "value": 1.3 + }, + "thickness": { + "type": "const" + }, + "directionMode": "FIXED" + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.5, + "to": 2.5 + }, + "speed": { + "type": "range", + "from": 0.3, + "to": 0.6 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": -0.2 + } + }, + "modules": [ + { + "type": "hollowengine:vfx/noise", + "strength": { + "type": "const", + "value": 0.3 + } + } + ] + }, + { + "type": "hollowengine:vfx/beam", + "id": "arc", + "name": "Arc", + "transform": { + "position": [ + 0.0, + 0.1, + 0.0 + ] + }, + "tint": { + "type": "channels", + "r": { + "type": "const", + "value": 0.6 + }, + "g": { + "type": "const", + "value": 0.85 + }, + "a": { + "type": "expr", + "source": "0.6 + 0.4 * math.sin(e.time * 1440)" + } + }, + "target": "rune_0", + "width": { + "type": "const", + "value": 0.06 + }, + "segments": 32, + "noise": { + "type": "const", + "value": 0.2 + }, + "noiseFrequency": 2.0, + "noiseSpeed": 8.0 + } + ] + }, + { + "type": "hollowengine:vfx/sphere", + "id": "shield", + "name": "Shield", + "transform": { + "position": [ + 0.0, + 1.0, + 0.0 + ], + "scale": [ + 2.4, + 2.4, + 2.4 + ] + }, + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE", + "shader": "hollowengine:vfx/force_field", + "uniforms": [ + { + "name": "FieldColor", + "value": { + "type": "color", + "value": { + "type": "solid", + "color": { + "r": 0.35, + "g": 0.7 + } + } + } + }, + { + "name": "FresnelPower", + "value": { + "type": "float", + "value": { + "type": "const", + "value": 2.5 + } + } + }, + { + "name": "IntersectionWidth", + "value": { + "type": "float", + "value": { + "type": "const", + "value": 0.4 + } + } + } + ] + }, + "segments": 48 + } + ], + "timeline": { + "duration": 4.0, + "tracks": [ + { + "node": "orbit", + "property": "transform.rotation", + "curves": [ + { + "channel": 1, + "keys": [ + { + "interpolation": "LINEAR" + }, + { + "time": 4.0, + "value": 360.0, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "shield", + "property": "transform.scale", + "curves": [ + { + "keys": [ + { + "value": 2.4 + }, + { + "time": 2.0, + "value": 2.7 + }, + { + "time": 4.0, + "value": 2.4 + } + ] + }, + { + "channel": 1, + "keys": [ + { + "value": 2.4 + }, + { + "time": 2.0, + "value": 2.7 + }, + { + "time": 4.0, + "value": 2.4 + } + ] + }, + { + "channel": 2, + "keys": [ + { + "value": 2.4 + }, + { + "time": 2.0, + "value": 2.7 + }, + { + "time": 4.0, + "value": 2.4 + } + ] + } + ] + } + ] + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/sky_strike.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/sky_strike.vfx new file mode 100644 index 000000000..79784b336 --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/sky_strike.vfx @@ -0,0 +1,196 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/beam", + "id": "pillar", + "name": "Pillar", + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE", + "glow": 2.5 + }, + "tint": { + "type": "solid", + "color": { + "g": 0.8, + "b": 0.4 + } + }, + "end": [ + 0.0, + 200.0, + 0.0 + ], + "width": { + "type": "expr", + "source": "2.4 * clamp(p.age * 6, 0, 1) * clamp(1 - (p.age - 0.8) / 1.2, 0, 1) * (1 - 0.4 * p.progress)" + }, + "segments": 32, + "noise": { + "type": "const", + "value": 0.25 + }, + "noiseFrequency": 0.05, + "noiseSpeed": 6.0 + }, + { + "type": "hollowengine:vfx/beam", + "id": "pillar-core", + "name": "Pillar Core", + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE", + "glow": 1.5 + }, + "end": [ + 0.0, + 200.0, + 0.0 + ], + "width": { + "type": "expr", + "source": "0.7 * clamp(p.age * 6, 0, 1) * clamp(1 - (p.age - 0.8) / 1.2, 0, 1) * (1 - 0.4 * p.progress)" + }, + "segments": 2 + }, + { + "type": "hollowengine:vfx/sphere", + "id": "flash", + "name": "Ground Flash", + "transform": { + "scale": [ + 0.0, + 0.0, + 0.0 + ] + }, + "material": { + "texture": "hollowengine:textures/particle/white.png", + "blend": "ADDITIVE", + "glow": 3.0 + }, + "tint": { + "type": "solid", + "color": { + "g": 0.8, + "b": 0.4 + } + } + }, + { + "type": "hollowengine:vfx/sky", + "id": "sky", + "name": "Golden Sky", + "uniforms": [ + { + "name": "Radius", + "value": { + "type": "float", + "value": { + "type": "expr", + "source": "clamp((p.age - 0.25) * 70, 0, 180)" + } + } + }, + { + "name": "Width", + "value": { + "type": "float", + "value": { + "type": "const", + "value": 8.0 + } + } + }, + { + "name": "Opacity", + "value": { + "type": "float", + "value": { + "type": "expr", + "source": "clamp((7 - p.age) / 1.5, 0, 1)" + } + } + } + ] + }, + { + "type": "hollowengine:vfx/camera_shake", + "id": "shake", + "strength": { + "type": "expr", + "source": "max(0, 1 - p.age * 1.5) * clamp(1 - n.camera_distance / 64, 0, 1) * 2.5" + }, + "frequency": { + "type": "const", + "value": 18.0 + }, + "amplitude": [ + 1.2, + 1.2, + 1.6 + ] + } + ], + "timeline": { + "duration": 7.0, + "tracks": [ + { + "node": "flash", + "property": "transform.scale", + "curves": [ + { + "keys": [ + { + "interpolation": "LINEAR" + }, + { + "time": 0.08, + "value": 3.0, + "interpolation": "LINEAR" + }, + { + "time": 1.2, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 1, + "keys": [ + { + "interpolation": "LINEAR" + }, + { + "time": 0.08, + "value": 3.0, + "interpolation": "LINEAR" + }, + { + "time": 1.2, + "interpolation": "LINEAR" + } + ] + }, + { + "channel": 2, + "keys": [ + { + "interpolation": "LINEAR" + }, + { + "time": 0.08, + "value": 3.0, + "interpolation": "LINEAR" + }, + { + "time": 1.2, + "interpolation": "LINEAR" + } + ] + } + ] + } + ] + } +} diff --git a/runtime/src/main/resources/assets/hollowengine/vfx/soul.vfx b/runtime/src/main/resources/assets/hollowengine/vfx/soul.vfx new file mode 100644 index 000000000..e1732435e --- /dev/null +++ b/runtime/src/main/resources/assets/hollowengine/vfx/soul.vfx @@ -0,0 +1,241 @@ +{ + "nodes": [ + { + "type": "hollowengine:vfx/group", + "id": "figure", + "name": "Figure", + "children": [ + { + "type": "hollowengine:vfx/model", + "id": "body", + "name": "Body", + "model": "hollowengine:models/entity/player_model.gltf" + }, + { + "type": "hollowengine:vfx/emitter", + "id": "soul", + "name": "Soul", + "children": [ + { + "type": "hollowengine:vfx/plane", + "id": "soul-plane", + "material": { + "blend": "ADDITIVE" + }, + "particle": { + "size": { + "x": { + "type": "const", + "value": 0.07 + }, + "y": { + "type": "const", + "value": 1.0 + }, + "z": { + "type": "const", + "value": 1.0 + } + }, + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.6, + "g": 0.9, + "a": 0.0 + } + }, + { + "position": 0.2, + "color": { + "r": 0.6, + "g": 0.9, + "a": 0.9 + } + }, + { + "position": 1.0, + "color": { + "r": 0.3, + "g": 0.5, + "a": 0.0 + } + } + ] + } + } + } + }, + { + "type": "hollowengine:vfx/trail", + "id": "soul-trail", + "tint": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.6, + "g": 0.9, + "a": 0.5 + } + }, + { + "position": 1.0, + "color": { + "r": 0.6, + "g": 0.9, + "a": 0.0 + } + } + ] + } + }, + "width": { + "type": "curve", + "curve": { + "keys": [ + { + "value": 0.03, + "interpolation": "LINEAR" + }, + { + "time": 1.0, + "interpolation": "LINEAR" + } + ] + } + }, + "lifetime": 0.4, + "maxPoints": 16, + "particle": { + "color": { + "type": "gradient", + "gradient": { + "stops": [ + { + "color": { + "r": 0.6, + "g": 0.9, + "a": 0.0 + } + }, + { + "position": 0.2, + "color": { + "r": 0.6, + "g": 0.9, + "a": 0.9 + } + }, + { + "position": 1.0, + "color": { + "r": 0.3, + "g": 0.5, + "a": 0.0 + } + } + ] + } + } + } + } + ], + "emission": { + "rate": { + "type": "const", + "value": 60.0 + }, + "duration": 0.0 + }, + "shape": { + "kind": "MODEL", + "model": "hollowengine:models/entity/player_model.gltf" + }, + "spawn": { + "lifetime": { + "type": "range", + "from": 1.0, + "to": 2.0 + }, + "speed": { + "type": "range", + "from": 0.1, + "to": 0.3 + } + }, + "motion": { + "gravity": { + "type": "const", + "value": -0.6 + }, + "drag": { + "type": "const", + "value": 0.5 + } + } + } + ] + }, + { + "type": "hollowengine:vfx/post_effect", + "id": "grayscale", + "name": "Grayscale", + "uniforms": [ + { + "name": "Strength" + } + ] + } + ], + "timeline": { + "duration": 4.0, + "tracks": [ + { + "node": "figure", + "property": "transform.rotation", + "curves": [ + { + "channel": 1, + "keys": [ + { + "interpolation": "LINEAR" + }, + { + "time": 4.0, + "value": 360.0, + "interpolation": "LINEAR" + } + ] + } + ] + }, + { + "node": "grayscale", + "property": "uniforms.Strength", + "curves": [ + { + "keys": [ + {}, + { + "time": 1.0, + "value": 1.0 + }, + { + "time": 3.0, + "value": 1.0 + }, + { + "time": 4.0 + } + ] + } + ] + } + ] + } +} diff --git a/runtime/src/main/resources/assets/minecraft/shaders/include/hollowengine_vfx.glsl b/runtime/src/main/resources/assets/minecraft/shaders/include/hollowengine_vfx.glsl new file mode 100644 index 000000000..2492c707a --- /dev/null +++ b/runtime/src/main/resources/assets/minecraft/shaders/include/hollowengine_vfx.glsl @@ -0,0 +1,15 @@ +// What the effect shaders share. A shader of an effect author imports it the same way: +// #moj_import + +// Distance from the eye along the view axis for a depth-buffer value, under the projection proj. +float hollowengine_view_depth(mat4 proj, float depth) { + return proj[3][2] / (depth * 2.0 - 1.0 + proj[2][2]); +} + +// 0 where the fragment touches the scene behind it, 1 once it is softness blocks in front of it. +float hollowengine_soft_fade(sampler2D sceneDepth, vec2 screenSize, mat4 proj, float softness) { + if (softness <= 0.0) return 1.0; + float scene = hollowengine_view_depth(proj, texture(sceneDepth, gl_FragCoord.xy / screenSize).r); + float own = hollowengine_view_depth(proj, gl_FragCoord.z); + return clamp((scene - own) / softness, 0.0, 1.0); +} diff --git a/runtime/src/test/kotlin/VfxEffectTreeTests.kt b/runtime/src/test/kotlin/VfxEffectTreeTests.kt new file mode 100644 index 000000000..eb919264d --- /dev/null +++ b/runtime/src/test/kotlin/VfxEffectTreeTests.kt @@ -0,0 +1,67 @@ +import org.junit.jupiter.api.Test +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmitterSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxGroupSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxKey +import ru.hollowhorizon.hollowengine.common.vfx.VfxPlaneSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxTimelineSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTrack +import ru.hollowhorizon.hollowengine.common.vfx.VfxTrackCurve +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + +class VfxEffectTreeTests { + private val effect = VfxEffect( + nodes = listOf( + VfxGroupSpec( + id = "group-a", + children = listOf( + VfxEmitterSpec(id = "emitter-a", children = listOf(VfxPlaneSpec(id = "plane-a"))), + ), + ), + VfxPlaneSpec(id = "plane-b"), + ), + timeline = VfxTimelineSpec( + tracks = listOf( + VfxTrack("plane-a", VfxProperty.SCALE, listOf(VfxTrackCurve(keys = listOf(VfxKey(0f, 1f))))), + ), + ), + ) + + @Test + fun `a node cannot be moved under itself or its own children`() { + assertSame(effect, effect.withMoved("group-a", "emitter-a")) + assertSame(effect, effect.withMoved("group-a", "group-a")) + } + + @Test + fun `a moved node leaves its old parent and lands at the index asked for`() { + val moved = effect.withMoved("plane-b", "emitter-a", 0) + assertEquals(listOf("group-a"), moved.nodes.map { it.id }) + assertEquals(listOf("plane-b", "plane-a"), moved.node("emitter-a")?.children?.map { it.id }) + } + + @Test + fun `shifting stays among the siblings`() { + val up = effect.withShifted("plane-b", -1) + assertEquals(listOf("plane-b", "group-a"), up.nodes.map { it.id }) + assertSame(up, up.withShifted("plane-b", -1)) + } + + @Test + fun `a duplicate gets fresh ids all the way down and takes the tracks along`() { + val (copied, id) = assertNotNull(effect.withDuplicate("emitter-a")) + val children = copied.node("group-a")?.children.orEmpty() + assertEquals(2, children.size) + assertEquals("emitter-a", children[0].id) + assertEquals(id, children[1].id) + + val plane = children[1].children.single() + assertNotEquals("plane-a", plane.id) + assertEquals(setOf("plane-a", plane.id), copied.timeline.tracks.map { it.node }.toSet()) + assertEquals(copied.walk().size, copied.walk().map { it.id }.toSet().size) + } +} diff --git a/runtime/src/test/kotlin/VfxFormatTests.kt b/runtime/src/test/kotlin/VfxFormatTests.kt new file mode 100644 index 000000000..f237a565e --- /dev/null +++ b/runtime/src/test/kotlin/VfxFormatTests.kt @@ -0,0 +1,215 @@ +import org.junit.jupiter.api.Test +import ru.hollowhorizon.hollowengine.client.vfx.VfxExpressionLanguage +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxAppearance +import ru.hollowhorizon.hollowengine.common.vfx.VfxBlend +import ru.hollowhorizon.hollowengine.common.vfx.VfxColorValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurve +import ru.hollowhorizon.hollowengine.common.vfx.VfxCurveInput +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmission +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmitterSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxFormat +import ru.hollowhorizon.hollowengine.common.vfx.VfxGradient +import ru.hollowhorizon.hollowengine.common.vfx.VfxGradientStop +import ru.hollowhorizon.hollowengine.common.vfx.VfxGroupSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxKey +import ru.hollowhorizon.hollowengine.common.vfx.VfxMaterialSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPlaneSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxPostEffectSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxProperty +import ru.hollowhorizon.hollowengine.common.vfx.VfxRgba +import ru.hollowhorizon.hollowengine.common.vfx.VfxSamplerSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxShape +import ru.hollowhorizon.hollowengine.common.vfx.VfxShapeKind +import ru.hollowhorizon.hollowengine.common.vfx.VfxSpawn +import ru.hollowhorizon.hollowengine.common.vfx.VfxSphereSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTimelineSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTrack +import ru.hollowhorizon.hollowengine.common.vfx.VfxTrackCurve +import ru.hollowhorizon.hollowengine.common.vfx.VfxTrailSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxTransform +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxUvRect +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import ru.hollowhorizon.hollowengine.common.vfx.VfxVec3Value +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceKind +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxForceSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxNoiseSpec +import ru.hollowhorizon.hollowengine.common.vfx.modules.VfxUvAnimationSpec +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The `.vfx` format has to survive a round trip. + * + * Effects are written by an editor that rewrites the whole file, so anything the reader drops is + * work silently thrown away. That makes the round trip the one test worth keeping here: it covers + * the polymorphic node and module registries, the sealed value shapes and the timeline at once. + */ +class VfxFormatTests { + @Test + fun `an effect survives a round trip`() { + val effect = sample() + + val text = VfxFormat.write(effect) + val read = VfxFormat.read(text) + + assertEquals(effect, read) + } + + @Test + fun `a node keeps its modules and their kinds`() { + val read = VfxFormat.read(VfxFormat.write(sample())) + val emitter = assertIs(read.nodes.first().children.first()) + + assertEquals(3, emitter.modules.size) + val swirl = assertIs(emitter.modules[0]) + assertIs(emitter.modules[1]) + assertIs(emitter.modules[2]) + assertEquals(VfxForceKind.VORTEX, swirl.kind) + } + + @Test + fun `renderers keep their place under the emitter`() { + val read = VfxFormat.read(VfxFormat.write(sample())) + val emitter = read.nodes.first().children.first() + + assertIs(emitter.children[0]) + assertIs(emitter.children[1]) + assertEquals(emitter, read.parentOf("flame-plane")) + } + + @Test + fun `a track names its property by id`() { + val text = VfxFormat.write(sample()) + + assertTrue("\"property\": \"uniforms.FieldColor\"" in text, "A property id is not written as a plain string") + } + + @Test + fun `an empty file reads as an empty effect`() { + assertEquals(VfxEffect.EMPTY, VfxFormat.read("")) + } + + @Test + fun `the shipped effects read and compile`() { + val folder = javaClass.classLoader.getResource(EXAMPLES_PATH)?.toURI()?.let(::File) + ?: error("The example effects are missing from the resources: $EXAMPLES_PATH") + val files = folder.listFiles { file -> file.name.endsWith(VfxFormat.EXTENSION) }.orEmpty() + assertTrue(files.isNotEmpty(), "No example effects in $EXAMPLES_PATH") + + files.forEach { file -> + val text = file.readText() + val effect = VfxFormat.read(text) + + assertTrue(effect.nodes.isNotEmpty(), "${file.name} has no nodes") + assertEquals(text.trim(), VfxFormat.write(effect).trim(), "${file.name} is not in canonical form") + + val sources = effect.expressions() + if (sources.isEmpty()) return@forEach + val diagnostics = VfxExpressionLanguage.compile(sources).diagnostics + assertTrue(diagnostics.isEmpty(), "${file.name} has expressions that do not compile: $diagnostics") + } + } + + private fun sample() = VfxEffect( + nodes = listOf( + VfxGroupSpec( + id = "core", + name = "Core", + transform = VfxTransform(position = Vec3f(0f, 0.25f, 0f)), + children = listOf(flame(), field()), + ), + VfxEmitterSpec( + id = "smoke", + name = "Smoke", + modules = listOf(VfxNoiseSpec(strength = VfxValue.Const(0.5f))), + ), + VfxPostEffectSpec(id = "gray"), + ), + timeline = VfxTimelineSpec( + duration = 3f, + tracks = listOf( + VfxTrack( + node = "smoke", + property = VfxProperty.ENABLED, + curves = listOf(VfxTrackCurve(keys = listOf(VfxKey(0f, 0f), VfxKey(1f, 1f)))), + ), + VfxTrack( + node = "flame", + property = VfxProperty.module("swirl", "strength"), + curves = listOf(VfxTrackCurve(keys = listOf(VfxKey(0f, 0f), VfxKey(2f, 3f)))), + ), + VfxTrack( + node = "field", + property = VfxProperty.uniform("FieldColor"), + curves = listOf(VfxTrackCurve(channel = 3, keys = listOf(VfxKey(0f, 1f), VfxKey(1f, 0f)))), + ), + ), + ), + ) + + private fun flame() = VfxEmitterSpec( + id = "flame", + name = "Flame", + emission = VfxEmission(rate = VfxValue.Const(40f), burst = 3, duration = 0f), + shape = VfxShape( + kind = VfxShapeKind.CONE, + radius = VfxValue.Expr("0.1 + e.age * 0.05"), + angle = VfxValue.Const(12f), + ), + spawn = VfxSpawn( + lifetime = VfxValue.Range(0.4f, 0.9f), + speed = VfxValue.Range(0.5f, 1.2f), + ), + modules = listOf( + VfxForceSpec(id = "swirl", kind = VfxForceKind.VORTEX, strength = VfxValue.Expr("p.progress * 2")), + VfxForceSpec(id = "wind", direction = Vec3f(1f, 0f, 0f)), + VfxUvAnimationSpec(columns = 2, rows = 2), + ), + children = listOf( + VfxPlaneSpec( + id = "flame-plane", + material = VfxMaterialSpec(blend = VfxBlend.ADDITIVE, uv = VfxUvRect(0f, 0f, 0.5f, 0.5f)), + particle = VfxAppearance( + size = VfxVec3Value( + VfxValue.OverTime(VfxCurve(listOf(VfxKey(0f, 0.2f), VfxKey(1f, 0f))), input = VfxCurveInput.SPEED), + VfxValue.ONE, + VfxValue.ONE, + ), + color = VfxColorValue.Gradient( + VfxGradient( + listOf( + VfxGradientStop(0f, VfxRgba(1f, 1f, 1f, 1f)), + VfxGradientStop(1f, VfxRgba(1f, 0.2f, 0f, 0f)), + ) + ) + ), + ), + ), + VfxTrailSpec(id = "flame-trail", maxPoints = 12), + ), + ) + + private fun field() = VfxSphereSpec( + id = "field", + segments = 32, + material = VfxMaterialSpec( + shader = "hollowengine:vfx/force_field", + uniforms = listOf( + VfxUniformSpec("FieldColor", VfxUniformValue.Color(VfxColorValue.Solid(VfxRgba(0.3f, 0.7f, 1f)))), + VfxUniformSpec("FresnelPower", VfxUniformValue.Scalar(VfxValue.Expr("2 + n.camera_distance"))), + VfxUniformSpec("Offset", VfxUniformValue.Vector(VfxVec3Value.all(0.5f))), + ), + samplers = listOf(VfxSamplerSpec("Pattern", "hollowengine:textures/particle/circle.png")), + ), + ) + + private companion object { + const val EXAMPLES_PATH = "assets/hollowengine/vfx" + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt index 9f9967b95..64aaef5bb 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt @@ -76,6 +76,8 @@ class HollowIdeFileTypeRegistryTest { soundsEditor = {}, animatorEditor = {}, rigEditor = {}, + vfxEditor = {}, + shaderGraphEditor = {}, textEditor = {}, ) } @@ -86,6 +88,8 @@ class HollowIdeFileTypeRegistryTest { assertEquals("sounds", registry.find("assets/demo/sounds.json", "{}".toByteArray())?.id) assertEquals("animator", registry.find("assets/demo/models/player.animator", byteArrayOf())?.id) assertEquals("rig", registry.find("assets/demo/models/player.glb.rig", byteArrayOf())?.id) + assertEquals("vfx", registry.find("assets/demo/vfx/flame.vfx", byteArrayOf())?.id) + assertEquals("material", registry.find("assets/demo/materials/fire.material", byteArrayOf())?.id) assertEquals("text", registry.find("assets/demo/other.json", "{}".toByteArray())?.id) assertEquals("text", registry.find("scripts/example.kts", "println(1)".toByteArray())?.id) assertNull(registry.find("unknown.bin", ByteArray(64) { 0 })) 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 85eba10a0..7b91e0dd0 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 @@ -85,7 +85,7 @@ class HollowTimelinePointerTest { AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV"), 70f), ) listOf(translation, fov).forEach { property -> - property.layers.first().channels.forEach { curve -> + property.curves.forEach { curve -> listOf(0.5f, 1.5f, 2.5f).forEach { time -> curve.keyframes += Keyframe(time, time) } } } @@ -121,7 +121,7 @@ class HollowTimelinePointerTest { listOf("Test"), AnimProperty("test.value", "Value", FloatPropertyType(), 0f), ) - val curve = property.layers.first().channels.first() + val curve = property.curves.first() times.forEachIndexed { index, time -> curve.keyframes += Keyframe(time, index.toFloat()) } controller.selectedKeyframes.clear() return controller to curve diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderExpressionTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderExpressionTest.kt new file mode 100644 index 000000000..fdac6634f --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderExpressionTest.kt @@ -0,0 +1,72 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ShaderExpressionTest { + @Test + fun `free names become pins, and the text reads them as floats`() { + val expression = ShaderExpression.parse("(sin(x) + 1) / 2 * a.x + PI") + + assertNull(expression.error) + assertEquals(listOf("x", "a"), expression.variables) + assertEquals("(sin((X)) + 1.0) / 2.0 * (A).x + 3.14159265", expression.glsl { it.uppercase() }) + } + + @Test + fun `nothing but an expression gets through`() { + listOf("a; discard", "a = 1", "{ a }", "a // b", "#define a", "a /* b */").forEach { text -> + assertNotNull(ShaderExpression.parse(text).error, text) + } + assertEquals(ShaderExpressionProblem.UNKNOWN_FUNCTION, ShaderExpression.parse("texture(a, b)").error?.problem) + assertEquals(ShaderExpressionProblem.RESERVED_NAME, ShaderExpression.parse("gl_FragCoord.x").error?.problem) + assertEquals(ShaderExpressionProblem.UNCLOSED, ShaderExpression.parse("sin(a").error?.problem) + assertEquals(ShaderExpressionProblem.EXPECTED_VALUE, ShaderExpression.parse("a * ").error?.problem) + // Spaced apart, the two minuses never read as a decrement. + assertFalse("--" in ShaderExpression.parse("a - -b").glsl { "1.0" }.orEmpty()) + } + + @Test + fun `an expression node has a pin per name, and a pin named like an engine input reads it`() { + val node = ShaderGraphNode("e", ShaderNodeLibrary.EXPRESSION, options = mapOf("expression" to "uv.x * k")) + val graph = ShaderGraph( + nodes = listOf(node, ShaderGraphNode("out", ShaderNodeLibrary.SURFACE_OUTPUT)), + links = listOf(ShaderGraphLink("e", "Out", "out", SurfaceOutputs.ALPHA)), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertEquals(listOf("uv", "k"), ShaderNodeTypes.of(node.type)!!.inputs(node).map { it.name }) + assertEquals(emptyList(), code.diagnostics) + assertTrue(code.fragment.text.contains("(sg_uv).x * (0.0)"), code.fragment.text) + } + + @Test + fun `the type of an expression is read off its text`() { + val widths = mapOf("v" to 3, "k" to 1) + fun width(text: String) = ShaderExpression.parse(text).width { widths[it] } + + assertEquals(3, width("v * k + 1")) + assertEquals(1, width("length(v) * k")) + assertEquals(2, width("v.xy")) + assertEquals(4, width("vec4(v, k)")) + assertEquals(1, width("k > 0.5 ? 1.0 : 0.0")) + assertNull(width("v * unknown")) + } + + @Test + fun `a wrong expression is reported on its node and compiles to zero`() { + val node = ShaderGraphNode("e", ShaderNodeLibrary.EXPRESSION, options = mapOf("expression" to "sin(", "type" to "vec3")) + val graph = ShaderGraph( + nodes = listOf(node, ShaderGraphNode("out", ShaderNodeLibrary.SURFACE_OUTPUT)), + links = listOf(ShaderGraphLink("e", "Out", "out", SurfaceOutputs.COLOR)), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertEquals(ShaderProblem.INVALID_EXPRESSION, code.diagnostics.single().problem) + assertTrue(code.fragment.text.contains("vec3 sg_0_Out = vec3(0.0);"), code.fragment.text) + } +} 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 new file mode 100644 index 000000000..50d17b33e --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphCompilerTest.kt @@ -0,0 +1,201 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ShaderGraphCompilerTest { + private val output = ShaderGraphNode("out", ShaderNodeLibrary.SURFACE_OUTPUT) + + private fun graph(vararg nodes: ShaderGraphNode, links: List) = + ShaderGraph(nodes = nodes.toList() + output, links = links) + + @Test + fun `the default graph draws what a material without one draws`() { + val code = ShaderGraphCompiler.compile(ShaderNodeLibrary.defaultSurface()) + + assertEquals(emptyList(), code.diagnostics) + assertTrue(code.fragment.text.contains("texture(Sampler0, sg_texture_uv)")) + assertTrue(code.fragment.text.contains("vec4 sg_1_RGBA = sg_color;"), code.fragment.text) + // A vec4 into the vec3 color is cut down to its first three components. + assertTrue(code.outputs.getValue(SurfaceOutputs.COLOR).endsWith(".xyz")) + assertTrue(code.vertex.statements.isEmpty()) + } + + @Test + fun `a dynamic node takes the widest input and spreads a number over it`() { + val graph = graph( + ShaderGraphNode("v", "hollowengine:input/vector3", values = mapOf("Value" to listOf(1f, 2f, 3f))), + ShaderGraphNode("m", "hollowengine:math/multiply", values = mapOf("B" to listOf(0.5f))), + links = listOf(ShaderGraphLink("v", "Out", "m", "A"), ShaderGraphLink("m", "Out", "out", SurfaceOutputs.COLOR)), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertEquals(ShaderType.VEC3, code.types.output("m", "Out")) + assertTrue(code.fragment.text.contains("vec3 sg_1_Out = sg_0_Out * vec3(0.5);"), code.fragment.text) + } + + @Test + fun `what only the vertex offset needs runs per vertex only`() { + val graph = graph( + ShaderGraphNode("time", "hollowengine:input/time"), + ShaderGraphNode("wave", "hollowengine:math/sine"), + links = listOf( + ShaderGraphLink("time", "Time", "wave", "In"), + ShaderGraphLink("wave", "Out", "out", SurfaceOutputs.VERTEX_OFFSET), + ), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertTrue(code.vertex.text.contains("sin(")) + assertFalse(code.fragment.text.contains("sin(")) + assertEquals("vec3(sg_1_Out)", code.outputs.getValue(SurfaceOutputs.VERTEX_OFFSET)) + } + + @Test + fun `reading the scene per vertex is reported`() { + val graph = graph( + ShaderGraphNode("depth", "hollowengine:input/scene_depth"), + links = listOf(ShaderGraphLink("depth", "Difference", "out", SurfaceOutputs.VERTEX_OFFSET)), + ) + val problems = ShaderGraphCompiler.compile(graph).diagnostics.map { it.problem } + + assertTrue(ShaderProblem.FRAGMENT_ONLY_IN_VERTEX in problems) + } + + @Test + fun `a cycle is reported and its nodes are left out`() { + val graph = graph( + ShaderGraphNode("a", "hollowengine:math/add"), + ShaderGraphNode("b", "hollowengine:math/add"), + links = listOf( + ShaderGraphLink("a", "Out", "b", "A"), + ShaderGraphLink("b", "Out", "a", "A"), + ShaderGraphLink("b", "Out", "out", SurfaceOutputs.ALPHA), + ), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertEquals(setOf("a", "b"), code.diagnostics.filter { it.problem == ShaderProblem.CYCLE }.map { it.node }.toSet()) + assertEquals("1.0", code.outputs.getValue(SurfaceOutputs.ALPHA)) + } + + @Test + fun `a texture property is sampled by its uniform`() { + val graph = ShaderGraph( + nodes = listOf( + ShaderGraphNode("noise", ShaderNodeLibrary.PROPERTY, options = mapOf("property" to "Noise")), + ShaderGraphNode("sample", "hollowengine:texture/sample"), + output, + ), + links = listOf( + ShaderGraphLink("noise", "Out", "sample", "Texture"), + ShaderGraphLink("sample", "RGB", "out", SurfaceOutputs.COLOR), + ), + properties = listOf(ShaderGraphProperty("Noise", ShaderType.TEXTURE)), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertEquals(emptyList(), code.diagnostics) + assertTrue(code.fragment.text.contains("texture(p_Noise, sg_texture_uv)"), code.fragment.text) + assertTrue(ShaderGraphTemplates.surface(code, ShaderGraphTemplates.PARTICLE).fragment.contains("uniform sampler2D p_Noise;")) + } + + @Test + fun `the preview program numbers the nodes that show a preview, the output last`() { + val preview = ShaderGraphCompiler.compilePreview(ShaderNodeLibrary.defaultSurface()) + + // The particle color and the split start with their previews off. + assertEquals(listOf("texture", "tint", "output"), preview.previewIndex.keys.toList()) + assertTrue(preview.selection.contains("PreviewNode == 2) fragColor = sg_surface("), preview.selection) + assertTrue(ShaderGraphTemplates.preview(preview).contains("uniform int PreviewNode;")) + } + + @Test + fun `a value typed on a node is a uniform of the preview, so changing it changes no code`() { + val graph = graph( + ShaderGraphNode("m", "hollowengine:math/multiply", values = mapOf("B" to listOf(0.5f))), + links = listOf(ShaderGraphLink("m", "Out", "out", SurfaceOutputs.ALPHA)), + ) + val preview = ShaderGraphCompiler.compilePreview(graph) + val changed = ShaderGraphCompiler.compilePreview(graph.withValue("m", "B", listOf(0.75f))) + + assertTrue(preview.statements.text.contains("PreviewValues["), preview.statements.text) + assertEquals(ShaderGraphTemplates.preview(preview), ShaderGraphTemplates.preview(changed)) + assertTrue(ShaderGraphTemplates.preview(preview).contains("uniform vec4 PreviewValues[${preview.values.size}];")) + } + + @Test + fun `only what reads the normal, the view or the position is previewed on the mesh`() { + val graph = graph( + ShaderGraphNode("fresnel", "hollowengine:procedural/fresnel"), + ShaderGraphNode("tint", "hollowengine:math/multiply"), + ShaderGraphNode("noise", "hollowengine:procedural/value_noise"), + links = listOf(ShaderGraphLink("fresnel", "Out", "tint", "A"), ShaderGraphLink("noise", "Out", "tint", "B")), + ) + + assertEquals(setOf("fresnel", "tint", "out"), ShaderGraphCompiler.compilePreview(graph).spatial) + } + + @Test + fun `a noise over a position is the 3D one`() { + val graph = graph( + ShaderGraphNode("local", "hollowengine:input/object_position"), + ShaderGraphNode("flat", "hollowengine:procedural/gradient_noise"), + ShaderGraphNode("solid", "hollowengine:procedural/gradient_noise"), + ShaderGraphNode("sum", "hollowengine:math/add"), + links = listOf( + ShaderGraphLink("local", "Position", "solid", "UV"), + ShaderGraphLink("flat", "Out", "sum", "A"), + ShaderGraphLink("solid", "Out", "sum", "B"), + ShaderGraphLink("sum", "Out", "out", SurfaceOutputs.ALPHA), + ), + ) + val code = ShaderGraphCompiler.compile(graph).fragment.text + + assertTrue(code.contains("sg_gradient_noise(sg_uv * 8.0)"), code) + assertTrue(code.contains("sg_gradient_noise3(sg_0_Position * 8.0)"), code) + } + + @Test + fun `each stage gets only the functions it calls, so a derivative never reaches the vertex stage`() { + val graph = graph( + ShaderGraphNode("noise", "hollowengine:procedural/gradient_noise"), + ShaderGraphNode("heave", "hollowengine:normal/offset"), + ShaderGraphNode("bump", "hollowengine:normal/bump"), + ShaderGraphNode("light", "hollowengine:normal/lambert"), + links = listOf( + ShaderGraphLink("noise", "Out", "heave", "Amount"), + ShaderGraphLink("heave", "Offset", "out", SurfaceOutputs.VERTEX_OFFSET), + ShaderGraphLink("noise", "Out", "bump", "Height"), + ShaderGraphLink("bump", "Normal", "light", "Normal"), + ShaderGraphLink("light", "Light", "out", SurfaceOutputs.ALPHA), + ), + ) + val code = ShaderGraphCompiler.compile(graph) + + assertTrue(ShaderLibrary.NORMALS in code.fragmentLibraries) + assertFalse(ShaderLibrary.NORMALS in code.vertexLibraries) + assertTrue(ShaderLibrary.GRADIENT_NOISE in code.vertexLibraries) + } + + @Test + fun `the output and every offset node move the mesh in their previews, unless the offset needs a fragment`() { + fun displaced(kind: String, output: String) = ShaderGraphCompiler.compilePreview( + graph( + ShaderGraphNode("from", kind), + links = listOf(ShaderGraphLink("from", output, "out", SurfaceOutputs.VERTEX_OFFSET)), + ) + ).displaced + + assertEquals(setOf("from", "out"), displaced("hollowengine:normal/offset", "Offset")) + assertEquals(emptySet(), displaced("hollowengine:normal/surface", "Normal")) + } + + @Test + fun `a graph survives a round trip`() { + val graph = ShaderNodeLibrary.defaultSurface().copy(properties = listOf(ShaderGraphProperty("Speed", default = listOf(2f)))) + assertEquals(graph, ShaderGraphFormat.read(ShaderGraphFormat.write(graph))) + } +} 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 new file mode 100644 index 000000000..4ab7c1c76 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/shadergraph/ShaderGraphEditsTest.kt @@ -0,0 +1,59 @@ +package ru.hollowhorizon.hollowengine.client.shadergraph + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ShaderGraphEditsTest { + private val graph = ShaderGraph( + nodes = listOf( + ShaderGraphNode("a", "hollowengine:math/add"), + ShaderGraphNode("b", "hollowengine:math/add"), + ShaderGraphNode("c", "hollowengine:math/add"), + ), + ) + + @Test + fun `an input takes one link, and a link that would close a loop is not made`() { + val linked = graph.withLink("a", "Out", "c", "A").withLink("b", "Out", "c", "A") + assertEquals(listOf(ShaderGraphLink("b", "Out", "c", "A")), linked.links) + + // b feeds c, c feeds a; a feeding b would close the loop. + val chained = linked.withLink("c", "Out", "a", "A") + assertEquals(2, chained.links.size) + assertEquals(chained, chained.withLink("a", "Out", "b", "A")) + } + + @Test + fun `a shape has the pins of the shape it is, and a link into a pin it loses goes`() { + val shape = ShaderGraphNode("s", "hollowengine:procedural/shape", options = mapOf("shape" to "polygon")) + val withLink = graph.withNode(shape).withLink("a", "Out", "s", "Sides") + val kind = ShaderNodeTypes.of(shape.type)!! + + assertTrue("Sides" in kind.inputs(shape).map { it.name }) + val ring = withLink.withOption("s", "shape", "ring") + assertEquals(listOf("UV", "Width", "Height", "Thickness", "Softness"), kind.inputs(ring.node("s")!!).map { it.name }) + assertTrue(ring.links.none { it.to == "s" }) + } + + @Test + fun `duplicating nodes keeps the links between them and drops the ones that reach outside`() { + val linked = graph.withLink("a", "Out", "b", "A").withLink("b", "Out", "c", "A") + val (copied, ids) = linked.withDuplicates(setOf("a", "b")) + + assertEquals(2, ids.size) + assertEquals(listOf(ShaderGraphLink(ids[0], "Out", ids[1], "A")), copied.links - linked.links.toSet()) + } + + @Test + fun `renaming a property renames what its nodes read`() { + val withProperty = graph + .withProperty(ShaderGraphProperty("Speed")) + .withNode(ShaderGraphNode("p", ShaderNodeLibrary.PROPERTY, options = mapOf("property" to "Speed"))) + + val renamed = withProperty.withPropertyChanged("Speed", ShaderGraphProperty("Rate")) + + assertEquals(listOf("Rate"), renamed.properties.map { it.name }) + assertEquals("Rate", renamed.node("p")?.options?.get("property")) + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/NodeIdentityTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/NodeIdentityTest.kt new file mode 100644 index 000000000..50e9a26da --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/NodeIdentityTest.kt @@ -0,0 +1,32 @@ +package ru.hollowhorizon.hollowengine.client.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A node's id is its identity: a composable that is recomposed with another id must end up as a node + * with that id. A reused node kept its first id, so a dock panel that switched windows still answered + * clicks as the first window. + */ +class NodeIdentityTest { + @Test + fun `a node recomposed with another id carries the new id`() { + var window by mutableStateOf("timeline") + HollowUiSurface().use { surface -> + surface.setContent { + Box(id = "panel-$window-content", modifier = Modifier.size(100.px, 60.px).input(clickable = true)) + } + surface.frame(200f, 120f, 10f, 10f, 0L) + + window = "assets" + Snapshot.sendApplyNotifications() + val frame = surface.frame(200f, 120f, 10f, 10f, 1L) + + assertEquals("panel-assets-content", frame.hitTest(10f, 10f)?.node?.id) + } + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/TransformedLayerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/TransformedLayerTest.kt new file mode 100644 index 000000000..08f83a3b4 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/TransformedLayerTest.kt @@ -0,0 +1,43 @@ +package ru.hollowhorizon.hollowengine.client.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Nodes ease a change of their transform by default. A layer that follows the pointer, such as the + * world of a graph canvas, opts out, and its children must then be where it moved them on the very + * next frame, both drawn and hit there. + */ +class TransformedLayerTest { + @Test + fun `children of a layer without transitions follow its move and scale on the next frame`() { + var pan by mutableStateOf(0f) + var zoom by mutableStateOf(1f) + HollowUiSurface().use { surface -> + surface.setContent { + Box(mode = UiBoxMode.STACK, modifier = Modifier.size(400.px, 400.px)) { + Box( + mode = UiBoxMode.STACK, + modifier = Modifier.position(0.px, 0.px).size(100.percent, 100.percent) + .pivot(0.px, 0.px).translate(pan, pan).scale(zoom).transition(), + ) { + Box(id = "node", modifier = Modifier.position(10.px, 10.px).size(20.px, 20.px).input(clickable = true)) + } + } + } + surface.frame(400f, 400f, 0f, 0f, 0L) + + pan = 100f + zoom = 2f + Snapshot.sendApplyNotifications() + val frame = surface.frame(400f, 400f, 0f, 0f, 16L) + + // From 100 + 2 * 10 = 120 to 100 + 2 * 30 = 160. + assertEquals("node", frame.hitTest(150f, 150f)?.node?.id) + } + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/docking/DockingStateTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/docking/DockingStateTest.kt index 47cd003c5..49d66412d 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/docking/DockingStateTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/docking/DockingStateTest.kt @@ -40,6 +40,55 @@ class DockingStateTest { assertTrue(state.pinnedItems.isEmpty()) } + @Test + fun `a floating window dropped past the edge, or cut off by a resize, comes back inside`() { + val state = DockingState() + state.updateSpaceSize(800f, 600f) + state.openFloating(DockItem("test", "Test"), x = 20f, y = 30f, width = 240f, height = 180f) + val id = state.floatingWindows.single().id + + state.startDraggingWindow(id) + state.moveFloating(id, 900f, -200f) + state.updateSpaceSize(700f, 500f) + assertEquals(920f, state.floatingWindows.single().x, "a window in the hand is left where the pointer has it") + + state.finishDraggingWindow() + val dropped = state.floatingWindows.single() + assertEquals(460f, dropped.x, "dropped, it lies against the right edge") + assertEquals(0f, dropped.y) + + state.moveFloating(id, 0f, 400f) + state.updateSpaceSize(400f, 300f) + val shrunk = state.floatingWindows.single() + assertEquals(160f, shrunk.x) + assertEquals(120f, shrunk.y, "a smaller space pulls it up as well as left") + } + + @Test + fun `a split window shares the side panel with the top one and swaps with it`() { + val state = DockingState() + state.open(DockItem("project", "Project", pinnable = true)) + state.open(DockItem("scene", "Scene", pinnable = true)) + state.open(DockItem("assets", "Assets", pinnable = true)) + state.pin("project", DockSide.LEFT) + state.pin("assets", DockSide.LEFT) + state.pin("scene", DockSide.LEFT, DockStripeGroup.SPLIT) + state.togglePinned("project") + + assertEquals(listOf("project", "scene"), state.sidePanels(DockSide.LEFT).map { it.item.id }) + + assertTrue(state.swapSidePanels(DockSide.LEFT)) + assertEquals(listOf("scene", "project"), state.sidePanels(DockSide.LEFT).map { it.item.id }, "both stay open") + assertEquals( + listOf("scene", "assets", "project"), + state.pinnedOn(DockSide.LEFT).map { it.item.id }, + "the swapped buttons change groups, the rest of the stripe keeps its order", + ) + + state.togglePinned("assets") + assertEquals(listOf("assets", "project"), state.sidePanels(DockSide.LEFT).map { it.item.id }, "top replaces top only") + } + @Test fun `a stripe button opens its panel and closes it again`() { val state = DockingState() @@ -98,4 +147,60 @@ class DockingStateTest { val editorSplit = assertNotNull(preservedRoot.findSplitSeparating("project", "editor")) assertEquals(0.28f, editorSplit.fraction) } + + @Test + fun `the two halves of a stripe keep a window open each`() { + val state = DockingState() + state.open(DockItem("project", "Project", pinnable = true)) + state.open(DockItem("console", "Console", pinnable = true)) + state.pin("project", DockSide.LEFT) + state.pin("console", DockSide.LEFT, DockStripeGroup.BOTTOM) + + assertEquals("project", state.expandedOn(DockSide.LEFT)?.item?.id) + assertEquals("console", state.expandedIn(DockAnchor(DockSide.LEFT, DockStripeGroup.BOTTOM))?.item?.id) + + state.togglePinned("project") + assertNull(state.expandedOn(DockSide.LEFT)) + assertNotNull(state.expandedIn(DockAnchor(DockSide.LEFT, DockStripeGroup.BOTTOM)), "the bottom half is its own") + } + + @Test + fun `a click into an open parked window gives it the keyboard`() { + val state = DockingState() + state.open(DockItem("editor", "Editor")) + state.open(DockItem("timeline", "Timeline", pinnable = true)) + state.pin("timeline", DockSide.LEFT, DockStripeGroup.BOTTOM) + state.focus("editor") + + assertTrue(state.focusContent("${PinnedContentPrefix}timeline-content")) + assertEquals("timeline", state.focusedItemId) + } + + @Test + fun `a window dragged to the other half stays open there`() { + val state = DockingState() + state.open(DockItem("project", "Project", pinnable = true)) + state.pin("project", DockSide.RIGHT) + + assertTrue(state.movePinned("project", DockAnchor(DockSide.RIGHT, DockStripeGroup.BOTTOM), 0)) + assertNull(state.expandedOn(DockSide.RIGHT)) + assertEquals("project", state.expandedIn(DockAnchor(DockSide.RIGHT, DockStripeGroup.BOTTOM))?.item?.id) + } + + @Test + fun `a dragged tool window can be parked on a stripe and a stripe button can float again`() { + val state = DockingState() + state.open(DockItem("editor", "Editor")) + state.open(DockItem("project", "Project", pinnable = true)) + assertNotNull(state.beginDraggingTab("project", 10f, 10f)) + + assertTrue(state.pinDraggedWindow(DockAnchor(DockSide.LEFT, DockStripeGroup.BOTTOM))) + assertTrue(state.floatingWindows.isEmpty()) + assertEquals(DockStripeGroup.BOTTOM, state.pinnedItem("project")?.group) + + assertNotNull(state.undockPinned("project", 0f, 0f, "dock-stripe-button-project")) + assertFalse(state.isPinned("project")) + assertTrue(state.isFloating("project")) + assertEquals("project", state.floatingWindows.single().stack.items.single().id) + } } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewStateTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewStateTest.kt new file mode 100644 index 000000000..7a87689a7 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/graph/GraphViewStateTest.kt @@ -0,0 +1,26 @@ +package ru.hollowhorizon.hollowengine.client.ui.graph + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GraphViewStateTest { + @Test + fun `a grabbed point stays under the pointer while a zoom is still easing in`() { + val view = GraphViewState() + view.zoomBy(2f, aroundX = 300f, aroundY = 200f) + view.advance(1L) + view.advance(16_000_001L) + + view.grab(100f, 100f) + val grabbedX = view.toGraphX(100f) + val grabbedY = view.toGraphY(100f) + var frame = 16_000_001L + repeat(20) { step -> + view.dragTo(100f + step * 5f, 100f + step * 3f) + frame += 16_000_000L + view.advance(frame) + assertEquals(grabbedX, view.toGraphX(100f + step * 5f), 1e-3f) + assertEquals(grabbedY, view.toGraphY(100f + step * 3f), 1e-3f) + } + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleHistoryTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleHistoryTest.kt new file mode 100644 index 000000000..a12fabf1f --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/panels/ConsoleHistoryTest.kt @@ -0,0 +1,25 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide.panels + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConsoleHistoryTest { + private class ListStore(override val entries: MutableList) : ConsoleHistoryStore { + override fun add(entry: String) { + entries += entry + } + } + + @Test + fun `stepping past the newest entry brings back the text being typed`() { + val history = ConsoleHistory(ListStore(mutableListOf("time set day", "weather clear"))) + + assertEquals("weather clear", history.step("give @s", older = true)) + assertEquals("time set day", history.step("weather clear", older = true)) + assertNull(history.step("time set day", older = true)) + assertEquals("weather clear", history.step("time set day", older = false)) + assertEquals("give @s", history.step("weather clear", older = false)) + assertNull(history.step("give @s", older = false)) + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/ScrollbarPointerTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/ScrollbarPointerTest.kt index f08c60f2a..740fbd1ff 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/ScrollbarPointerTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/input/ScrollbarPointerTest.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.client.ui.input import org.junit.jupiter.api.Test import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollHandle +import ru.hollowhorizon.hollowengine.client.ui.style.compileHss import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -48,6 +49,38 @@ class ScrollbarPointerTest { } } + @Test + fun `hovering an overlay bar grows it without a click to relayout`() { + HollowUiSurface().use { surface -> + surface.setContent { + Box( + id = "viewport", + mode = UiBoxMode.STACK, + tags = listOf("bar"), + modifier = Modifier.size(100.px, 100.px) + .style(compileHss(".bar { scrollbar: 4px; scrollbar-overlay: true; scrollbar-hover-thickness: 8px; }")) + .then(scrollModifier(horizontal = false)), + ) { + Box(modifier = Modifier.size(60.px, 400.px)) + } + } + surface.frame(200f, 200f, -1f, -1f, 0L) + val rest = surface.frame(200f, 200f, -1f, -1f, 16_000_000L) + val viewport = rest.nodes.single { it.id == "viewport" } + val bar = rest.layout.scrollbars.getValue(viewport).single() + val track = rest.layout.nodes.getValue(bar).rect + assertEquals(4f, track.width, 0.01f) + + val x = track.x + track.width / 2f + val y = track.y + track.height / 2f + surface.frame(200f, 200f, x, y, 32_000_000L) + Thread.sleep(200L) + val hovered = surface.frame(200f, 200f, x, y, 48_000_000L) + + assertEquals(8f, hovered.layout.nodes.getValue(bar).rect.width, 0.01f, "the pointer alone must grow the bar") + } + } + @Test fun `dragging the thumb to the bottom scrolls to the end`() { surfaceWithScrollbar { surface, scroll, frame, viewport -> diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/InlineFlowTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/InlineFlowTest.kt index 9b8a0fc0f..c855e6894 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/InlineFlowTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/InlineFlowTest.kt @@ -6,10 +6,15 @@ import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollState import ru.hollowhorizon.hollowengine.client.ui.style.UiModifierResolver import ru.hollowhorizon.hollowengine.client.ui.style.compileHss import ru.hollowhorizon.hollowengine.client.ui.text.Bold +import ru.hollowhorizon.hollowengine.client.ui.text.UiTextFonts import kotlin.test.assertEquals import kotlin.test.assertTrue class InlineFlowTest { + /** A line is as tall as the test font's line, a little taller than its 10px em. */ + private fun lineOf(fontSize: Float) = UiTextFonts.resolve(TestFontFamily).lineHeight(fontSize) + private val Line = lineOf(10f) + private fun span(text: String, vararg mods: Modifier) = SpanNode(text, modifiers = mods.toList()) @@ -38,7 +43,7 @@ class InlineFlowTest { val s = span("aaaa bbbb cccc") val (layout, container) = flow(60f, s) val containerLayout = layout.nodes.getValue(container) - assertEquals(20f, containerLayout.rect.height, 0.6f, "two 10px lines") + assertEquals(Line * 2f, containerLayout.rect.height, 0.6f, "two 10px lines") val spanLayout = layout.nodes.getValue(s) assertEquals(2, spanLayout.textLayout!!.lines.size, "span split across two lines") @@ -48,7 +53,7 @@ class InlineFlowTest { fun `single line when everything fits`() { val s = span("aa bb") val (layout, container) = flow(200f, s) - assertEquals(10f, layout.nodes.getValue(container).rect.height, 0.6f) + assertEquals(Line, layout.nodes.getValue(container).rect.height, 0.6f) assertEquals(1, layout.nodes.getValue(s).textLayout!!.lines.size) } @@ -59,7 +64,7 @@ class InlineFlowTest { val textLayout = layout.nodes.getValue(s).textLayout!! assertEquals(2, textLayout.lines.size) - assertEquals(20f, layout.nodes.getValue(container).rect.height, 0.6f) + assertEquals(Line * 2f, layout.nodes.getValue(container).rect.height, 0.6f) assertEquals(0, textLayout.lines[0].sourceStart) assertEquals(3, textLayout.lines[0].sourceLength) assertEquals(3, textLayout.lines[1].sourceStart) @@ -174,7 +179,7 @@ class InlineFlowTest { } @Test - fun `text-align positions the line left, centre and right`() { + fun `text-align positions the line left, center and right`() { fun wordX(align: String): Float { val s = span("aa") // 12px wide in a 100px flow val (layout, container) = flow(100f, s, sheet = "#flow { text-align: $align; }") @@ -201,7 +206,7 @@ class InlineFlowTest { val word = span("aaaaaaaaaa") val comma = span(",") val (layout, container) = flow(67f, word, comma) - assertEquals(10f, layout.nodes.getValue(container).rect.height, 0.6f, "single line — comma glued to the word") + assertEquals(Line, layout.nodes.getValue(container).rect.height, 0.6f, "single line — comma glued to the word") } @Test @@ -211,7 +216,7 @@ class InlineFlowTest { val (layout, container) = flow(42f, revealed, pending) val revealedLayout = layout.nodes.getValue(revealed) - assertEquals(20f, layout.nodes.getValue(container).rect.height, 0.6f, "the whole word moves to the next line") + assertEquals(Line * 2f, layout.nodes.getValue(container).rect.height, 0.6f, "the whole word moves to the next line") assertEquals( revealedLayout.rect.y + revealedLayout.textLayout!!.lines.last().y, layout.nodes.getValue(pending).rect.y, @@ -225,7 +230,7 @@ class InlineFlowTest { val small = span("aa") val big = span("aa", Modifier.fontSize(20f)) val (layout, container) = flow(500f, small, big) - assertEquals(20f, layout.nodes.getValue(container).rect.height, 0.6f, "line height = tallest span") + assertEquals(lineOf(20f), layout.nodes.getValue(container).rect.height, 0.6f, "line height = tallest span") val bigRect = layout.nodes.getValue(big).rect assertEquals(26.667f, bigRect.width, 1.0f) } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/NestedInlineFlowTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/NestedInlineFlowTest.kt index a630e4f28..82faf00c8 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/NestedInlineFlowTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/NestedInlineFlowTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollState import ru.hollowhorizon.hollowengine.client.ui.style.UiModifierResolver +import ru.hollowhorizon.hollowengine.client.ui.text.UiTextFonts import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -37,10 +38,10 @@ class NestedInlineFlowTest { children.add(group) children.add(span("dd")) } - assertEquals(10f, layout.nodes.getValue(container).rect.height, 0.6f) + assertEquals(UiTextFonts.resolve(TestFontFamily).lineHeight(10f), layout.nodes.getValue(container).rect.height, 0.6f) val groupRect = layout.nodes.getValue(group).rect assertEquals(33.333f, groupRect.width, 1.5f) - assertEquals(10f, groupRect.height, 0.6f) + assertEquals(UiTextFonts.resolve(TestFontFamily).lineHeight(10f), groupRect.height, 0.6f) } @Test diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/OverflowClipTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/OverflowClipTest.kt new file mode 100644 index 000000000..098db73f9 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/OverflowClipTest.kt @@ -0,0 +1,48 @@ +package ru.hollowhorizon.hollowengine.client.ui.layout + +import org.junit.jupiter.api.Test +import ru.hollowhorizon.hollowengine.client.ui.BoxNode +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.UiColor +import ru.hollowhorizon.hollowengine.client.ui.UiMeasurePolicies +import ru.hollowhorizon.hollowengine.client.ui.border +import ru.hollowhorizon.hollowengine.client.ui.clip +import ru.hollowhorizon.hollowengine.client.ui.padding +import ru.hollowhorizon.hollowengine.client.ui.px +import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollState +import ru.hollowhorizon.hollowengine.client.ui.scrollModifier +import ru.hollowhorizon.hollowengine.client.ui.size +import ru.hollowhorizon.hollowengine.client.ui.style.UiModifierResolver +import kotlin.test.assertEquals + +class OverflowClipTest { + private fun clipOf(modifier: Modifier): UiRect { + val node = BoxNode(id = "node", measurePolicy = UiMeasurePolicies.box(), modifiers = listOf(modifier)) + val root = BoxNode(id = "root", measurePolicy = UiMeasurePolicies.box(), modifiers = emptyList()) + root.children.add(node) + UiModifierResolver().resolve(root) + return UiLayoutPipeline().compute(root, 300f, 300f, UiScrollState()).nodes.getValue(node).clip!! + } + + private val field = Modifier.size(100.px, 16.px).padding(4.px).border(1.px, UiColor.White) + + @Test + fun `a single-line field keeps the padding band along the axis it does not scroll`() { + val clip = clipOf(field.then(scrollModifier(vertical = false))) + + assertEquals(5f, clip.x) + assertEquals(90f, clip.width) + assertEquals(1f, clip.y) + assertEquals(14f, clip.height) + } + + @Test + fun `a container scrolling both ways and a plain clip both cut at the content edge`() { + val scrolling = clipOf(field.then(scrollModifier())) + val clipped = clipOf(field.clip()) + + for (clip in listOf(scrolling, clipped)) { + assertEquals(UiRect(5f, 5f, 90f, 6f), clip) + } + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/ScrollbarSynthesisTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/ScrollbarSynthesisTest.kt index 90a3de507..ced7219f1 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/ScrollbarSynthesisTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/layout/ScrollbarSynthesisTest.kt @@ -161,7 +161,7 @@ class ScrollbarSynthesisTest { } @Test - fun `a horizontal scrollbar keeps the container height and re-centres content in what is left`() { + fun `a horizontal scrollbar keeps the container height and re-centers content in what is left`() { fun horizontalLayout(childWidth: Float): Triple { val child = BoxNode( id = "content", @@ -188,7 +188,7 @@ class ScrollbarSynthesisTest { viewport.content.y + (viewport.content.height - child.rect.height) / 2f, child.rect.y, 0.01f, - "centred content re-centres inside what the bar left over", + "centered content re-centers inside what the bar left over", ) assertTrue(overflowLayout.scrollbars[overflowViewport].orEmpty().isNotEmpty()) } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/BorderDashPatternTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/BorderDashPatternTest.kt new file mode 100644 index 000000000..152657485 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/BorderDashPatternTest.kt @@ -0,0 +1,18 @@ +package ru.hollowhorizon.hollowengine.client.ui.render + +import org.junit.jupiter.api.Test +import ru.hollowhorizon.hollowengine.client.ui.UiBorderDash +import kotlin.math.PI +import kotlin.test.assertEquals + +class BorderDashPatternTest { + @Test + fun `the pattern closes round the ring on a whole number of repeats`() { + val pattern = borderDashPattern(width = 101f, height = 47f, radius = 8f, borderWidth = 1f, dash = UiBorderDash(5f, 4f)) + val perimeter = 2f * (100f - 15f) + 2f * (46f - 15f) + 2f * PI.toFloat() * 7.5f + val repeats = perimeter / pattern.period + + assertEquals(Math.round(repeats).toFloat(), repeats, 0.001f) + assertEquals(5f / 9f, pattern.length / pattern.period, 0.0001f, "stretching keeps the dash to gap ratio") + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/TransformedClipCullingTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/TransformedClipCullingTest.kt index f0f246a21..1af3e7eb3 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/TransformedClipCullingTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/TransformedClipCullingTest.kt @@ -28,6 +28,21 @@ class TransformedClipCullingTest { assertTrue(textCommands(translateX = -100f).isNotEmpty()) } + @Test + fun `a clipped box moved into the clip by an ancestor transform keeps its text`() { + val text = SpanNode("4") + val field = BoxNode(modifiers = listOf(Modifier.position((-150).px, 0.px).size(40.px, 20.px).clip())) + .also { it.children.add(text) } + val world = BoxNode(modifiers = listOf(Modifier.position(0.px, 0.px).size(100.px, 20.px).translate(x = 200f))) + .also { it.children.add(field) } + val root = BoxNode(modifiers = listOf(Modifier.size(100.px, 20.px).clip())).also { it.children.add(world) } + + val frame = HollowUiRuntime().frame(root, 100f, 20f, -1f, -1f, 0L) + val texts = UiCommandRenderer().collect(frame.root, frame.layout).filterIsInstance() + + assertTrue(texts.isNotEmpty()) + } + @Test fun `untransformed child outside clip remains culled`() { assertFalse(textCommands(translateX = null).isNotEmpty()) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatchTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatchTest.kt index fdb3e77f1..a9c11c9a5 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatchTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiAnalyticRectBatchTest.kt @@ -76,7 +76,7 @@ class UiAnalyticRectBatchTest { } @Test - fun `a glyph encodes marker, uv, colour and clip into its record`() { + fun `a glyph encodes marker, uv, color and clip into its record`() { val batch = UiAnalyticRectBatch() val clip = UiShaderClip(1f, 2f, 3f, 4f) batch.appendGlyph( @@ -97,7 +97,7 @@ class UiAnalyticRectBatchTest { assertEquals(UiAnalyticRectBatch.GlyphMarker, records[0], "glyph marker in texel 0") assertEquals(0f, records[3], "page index in texel 0") assertEquals(0.1f, records[4]); assertEquals(0.4f, records[7]) // uv rect (texel 1) - assertEquals(0.5f, records[8]); assertEquals(0.8f, records[11]) // colour (texel 2) + assertEquals(0.5f, records[8]); assertEquals(0.8f, records[11]) // color (texel 2) assertEquals(1f, records[12]); assertEquals(4f, records[15]) // clip (texel 3) } @@ -202,7 +202,7 @@ class UiAnalyticRectBatchTest { @Test fun `plain fill joins the analytic batch and is hard-filled by the shader`() { - // A flat colour rect (no radius, no border) batches here; the shader hard-fills it so abutting + // A flat color rect (no radius, no border) batches here; the shader hard-fills it so abutting // opaque fills stay seam-free while still sharing one instanced draw with rounded/bordered rects. val batch = UiAnalyticRectBatch() val command = command() diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiSvgDocumentRenderTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiSvgDocumentRenderTest.kt index d3abe95fa..cb7975273 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiSvgDocumentRenderTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiSvgDocumentRenderTest.kt @@ -125,7 +125,7 @@ class UiSvgDocumentRenderTest { val shadow = assertIs(effects.first { it is UiSvgFilterEffect.DropShadow }) assertEquals(1.6f, shadow.offsetX) assertEquals(1.6f, shadow.offsetY) - assertTrue(shadow.color.red > 0.9f && shadow.color.blue < 0.3f, "shadow lost its colour: ${shadow.color}") + assertTrue(shadow.color.red > 0.9f && shadow.color.blue < 0.3f, "shadow lost its color: ${shadow.color}") assertEquals(1, effects.count { it is UiSvgFilterEffect.GaussianBlur }) } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiTextClipTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiTextClipTest.kt index e41af3f74..bb902b54c 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiTextClipTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/render/UiTextClipTest.kt @@ -18,6 +18,7 @@ import ru.hollowhorizon.hollowengine.client.ui.layout.UiLayoutPipeline import ru.hollowhorizon.hollowengine.client.ui.scroll.UiScrollState import ru.hollowhorizon.hollowengine.client.ui.style.UiModifierResolver import ru.hollowhorizon.hollowengine.client.ui.style.UiTextOverflow +import ru.hollowhorizon.hollowengine.client.ui.text.UiTextRun import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -66,4 +67,32 @@ class UiTextClipTest { val displayed = UiTextOverflowResolver.ellipsizeLine(command, command.layout.lines.single()) assertTrue(displayed.text.endsWith("..."), "overflow result was '${displayed.text}'") } + + @Test + fun `shortened text keeps the spaces between its words`() { + val span = SpanNode("one two three four five six") + val text = BoxNode( + measurePolicy = UiMeasurePolicies.InlineFlow, + modifiers = listOf( + Modifier.size(70.px, 12.px).fontFamily(TestFontFamily).fontSize(10f) + .textWrap(false).textOverflow(UiTextOverflow.DOTS), + ), + ).also { node -> + node.children += span + span.layoutState.attachTo(node) + } + val root = BoxNode(measurePolicy = UiMeasurePolicies.Column).also { node -> + node.children += text + text.layoutState.attachTo(node) + } + UiModifierResolver().resolve(root, animate = false) + val layout = UiLayoutPipeline().compute(root, 100f, 100f, UiScrollState()) + val command = UiCommandRenderer().collect(root, layout).filterIsInstance().single() + + val displayed = UiTextOverflowResolver.ellipsizeLine(command, command.layout.lines.single()) + assertTrue(displayed.text.startsWith("one two"), "overflow result was '${displayed.text}'") + val runs = displayed.fragments.filterIsInstance() + assertTrue(runs.zipWithNext().all { (left, right) -> right.x > left.x + left.width - 0.01f }) + assertTrue(runs[1].x > runs[0].x + runs[0].width + 0.5f, "the words were drawn back to back") + } } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/HssVariablesTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/HssVariablesTest.kt new file mode 100644 index 000000000..acf1dcf4f --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/HssVariablesTest.kt @@ -0,0 +1,75 @@ +package ru.hollowhorizon.hollowengine.client.ui.style + +import org.junit.jupiter.api.Test +import ru.hollowhorizon.hollowengine.client.ui.BoxNode +import ru.hollowhorizon.hollowengine.client.ui.Modifier +import ru.hollowhorizon.hollowengine.client.ui.style +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class HssVariablesTest { + private val sheets = mapOf( + "test:palette.hss" to """ + ${'$'}base: 0.5; + ${'$'}half: ${'$'}base; + .imported { opacity: 0.1; } + """.trimIndent(), + "test:loop-a.hss" to """@import "test:loop-b.hss"; ${'$'}a: 0.3;""", + "test:loop-b.hss" to """@import "test:loop-a.hss"; ${'$'}b: 0.6;""", + ) + + private val reader = HssImportReader { sheets[it] } + + private fun compile(source: String): CompiledHss { + val document = parseHss(source) + return compileHss(document, scope = HssScope.of(document, reader)) + } + + private fun opacityOf(sheet: CompiledHss, vararg tags: String): Float { + val child = BoxNode(tags = tags.toList()) + val root = BoxNode(modifiers = listOf(Modifier.style(sheet))) + root.children.add(child) + UiModifierResolver().resolve(root, animate = false) + return child.resolvedSnapshot.opacity + } + + @Test + fun `a variable is written into the value, variables inside variables included`() { + val sheet = compile("${'$'}fade: ${'$'}inner; ${'$'}inner: 0.4; .a { opacity: ${'$'}fade; }") + assertEquals(0.4f, opacityOf(sheet, "a")) + } + + @Test + fun `an import brings its variables and its rules, and the importing sheet can retune them`() { + val sheet = compile("""@import "test:palette.hss"; ${'$'}base: 0.8; .a { opacity: ${'$'}half; }""") + assertEquals(0.8f, opacityOf(sheet, "a")) + assertEquals(0.1f, opacityOf(sheet, "imported")) + } + + @Test + fun `stylesheets that import each other are each read once`() { + val sheet = compile("""@import "test:loop-a.hss"; .a { opacity: ${'$'}b; } .b { opacity: ${'$'}a; }""") + assertEquals(0.6f, opacityOf(sheet, "a")) + assertEquals(0.3f, opacityOf(sheet, "b")) + } + + @Test + fun `a set is taken in where it is applied, and the rule's own declarations after it win`() { + val sheet = compile( + """ + ${'$'}faded { opacity: ${'$'}level; } + ${'$'}level: 0.3; + .a { @apply ${'$'}faded; } + .b { @apply ${'$'}faded; opacity: 0.9; } + """.trimIndent() + ) + assertEquals(0.3f, opacityOf(sheet, "a")) + assertEquals(0.9f, opacityOf(sheet, "b")) + } + + @Test + fun `a missing import and a variable that names itself fail instead of compiling to nothing`() { + assertFailsWith { compile("""@import "test:absent.hss";""") } + assertFailsWith { compile("${'$'}self: ${'$'}self; .a { opacity: ${'$'}self; }") } + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/TextEffectParsingTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/TextEffectParsingTest.kt index f96388349..57959f165 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/TextEffectParsingTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/style/TextEffectParsingTest.kt @@ -30,7 +30,7 @@ class TextEffectParsingTest { } @Test - fun `a rule takes thickness, offset and colour in that order`() { + fun `a rule takes thickness, offset and color in that order`() { val underline = parseTextEffect("underline(0.09, 0.04, #FF5555)") as Underline assertEquals(0.09f, underline.thickness) assertEquals(0.04f, underline.offset) 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 16f9e0c2d..7a3368f47 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 @@ -9,6 +9,7 @@ import ru.hollowhorizon.hollowengine.client.ui.Modifier import ru.hollowhorizon.hollowengine.client.ui.px import ru.hollowhorizon.hollowengine.client.ui.size import ru.hollowhorizon.hollowengine.client.ui.style.scrollable +import ru.hollowhorizon.hollowengine.client.ui.text.UiTextFonts import ru.hollowhorizon.hollowengine.client.ui.text.UiTextLayouter import ru.hollowhorizon.hollowengine.common.scripting.ide.TokenType import kotlin.test.Test @@ -50,7 +51,7 @@ class CodeInsightPopupLayoutTest { fun `signature popup grows for wrapped lines without scrolling inside a short editor`() { val state = TextFieldState("call(", multiline = true).apply { focus() } val label = "(screen: ResourceLocation, kind: UiSurfaceKind, charDelay: Int, " + - "choiceRevealDelay: Long)" + "choiceRevealDelay: Long, skipDelay: Int, portrait: ResourceLocation, voice: SoundEvent)" val provider = UiSignatureHelpProvider { UiTextSignatureHelp( anchor = state.text.length, @@ -75,7 +76,11 @@ class CodeInsightPopupLayoutTest { val popup = assertNotNull(current.nodeByIdentifier("editable-text-field-signature-help")) val popupLayout = current.layout[popup] - assertTrue(popupLayout.rect.height > state.fontSize + 16f, "wrapped lines must grow the popup") + val line = UiTextFonts.resolve(state.fontFamily).lineHeight(state.fontSize) + assertTrue( + popupLayout.rect.height > line * 1.5f, + "wrapped lines must grow the popup: ${popupLayout.rect} for a $line line", + ) assertEquals(0f, popupLayout.scrollRange.y, 0.01f, "two wrapped lines must not create a scrollbar") assertTrue(popupLayout.rect.width < 604f, "fit width must hug the longest wrapped line") } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextFieldKeyTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextFieldKeyTest.kt index 08c44bd1e..b3e750798 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextFieldKeyTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/widgets/EditableTextFieldKeyTest.kt @@ -146,6 +146,14 @@ class EditableTextFieldKeyTest { } } + @Test + fun `a single-line field leaves plain up and down to its host`() { + val s = TextFieldState(initialText = "say hi", initialCaret = 3) + assertFalse(s.press(GLFW.GLFW_KEY_UP)) + assertFalse(s.press(GLFW.GLFW_KEY_DOWN)) + assertEquals(3, s.caret) + } + @Test fun `ctrl-alt-down adds a caret on the next line`() { val s = state("ab\ncd", caret = 1) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfEdgeColoringTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfEdgeColoringTest.kt index 6754aa032..484e57e44 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfEdgeColoringTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfEdgeColoringTest.kt @@ -21,11 +21,11 @@ class MsdfEdgeColoringTest { } @Test - fun `a single corner splits the contour into three colour bands`() { + fun `a single corner splits the contour into three color bands`() { val shape = teardrop() shape.colorEdges() val colors = shape.contours.single().edges.map { it.color } - assertEquals(3, colors.toSet().size, "three bands, not one colour for the whole loop: $colors") + assertEquals(3, colors.toSet().size, "three bands, not one color for the whole loop: $colors") } @Test @@ -49,7 +49,7 @@ class MsdfEdgeColoringTest { } @Test - fun `a square's four corners still alternate colours`() { + fun `a square's four corners still alternate colors`() { val shape = MsdfShape().apply { contours += squareContour(left = 0f, bottom = 0f, size = 10f) } shape.colorEdges() val colors = shape.contours.single().edges.map { it.color } @@ -76,7 +76,7 @@ class MsdfEdgeColoringTest { } @Test - fun `a colouring that would drop a channel falls back to a plain field`() { + fun `a coloring that would drop a channel falls back to a plain field`() { val shape = MsdfShape() shape.contours += MsdfContour().apply { edges += MsdfEdge(floatArrayOf(0f, 0f, 10f, 0f)).also { it.color = MsdfChannel.CYAN } @@ -86,7 +86,7 @@ class MsdfEdgeColoringTest { assertEquals(MsdfChannel.WHITE, shape.usedChannels()) } - /** Corners as the colourer itself counts them, at the threshold it actually runs with. */ + /** Corners as the colorer itself counts them, at the threshold it actually runs with. */ private fun cornerCount(shape: MsdfShape): Int = findCorners(shape.contours.single().edges, sin(DefaultAngleThreshold)).size diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfGeneratorTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfGeneratorTest.kt index 5cf728f2c..0efb30e49 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfGeneratorTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/utils/font/MsdfGeneratorTest.kt @@ -21,7 +21,7 @@ class MsdfGeneratorTest { @Test fun `distance is positive inside the shape and negative outside`() { val field = squareField() - assertTrue(field.median(16, 16) > 0.5f, "the centre is inside: ${field.median(16, 16)}") + assertTrue(field.median(16, 16) > 0.5f, "the center is inside: ${field.median(16, 16)}") assertTrue(field.median(2, 2) < 0.5f, "the far corner is outside: ${field.median(2, 2)}") assertTrue(field.median(16, 2) < 0.5f, "below the square is outside: ${field.median(16, 2)}") } @@ -29,7 +29,7 @@ class MsdfGeneratorTest { @Test fun `the encoded distance crosses one half exactly on the edge`() { val field = squareField() - // Pixel centres sit at x + 0.5, so columns 7 and 8 straddle the edge at x = 8 by half a + // Pixel centers sit at x + 0.5, so columns 7 and 8 straddle the edge at x = 8 by half a // unit each way; with a range of 8 that is 1/16 of the encoded scale. assertEquals(0.5f - 0.5f / 8f, field.median(7, 16), Tolerance) assertEquals(0.5f + 0.5f / 8f, field.median(8, 16), Tolerance) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/vfx/VfxSubEmitterTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/vfx/VfxSubEmitterTest.kt new file mode 100644 index 000000000..bc75c3c3f --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/vfx/VfxSubEmitterTest.kt @@ -0,0 +1,73 @@ +package ru.hollowhorizon.hollowengine.client.vfx + +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f +import ru.hollowhorizon.hollowengine.common.vfx.VfxEffect +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmission +import ru.hollowhorizon.hollowengine.common.vfx.VfxEmitterSpec +import ru.hollowhorizon.hollowengine.common.vfx.VfxParticleEvent +import ru.hollowhorizon.hollowengine.common.vfx.VfxSpawn +import ru.hollowhorizon.hollowengine.common.vfx.VfxSubEmission +import ru.hollowhorizon.hollowengine.common.vfx.VfxTransform +import ru.hollowhorizon.hollowengine.common.vfx.VfxValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VfxSubEmitterTest { + private fun effect(event: VfxParticleEvent, count: Float) = VfxEffect( + nodes = listOf( + VfxEmitterSpec( + id = "rockets", + transform = VfxTransform(position = Vec3f(2f, 0f, 0f)), + emission = VfxEmission(rate = VfxValue.ZERO, burst = 4, duration = 5f, loop = false), + spawn = VfxSpawn(lifetime = VfxValue.Const(0.1f), speed = VfxValue.ZERO), + children = listOf( + VfxEmitterSpec( + id = "sparks", + // A sub-emitter spawns where the particle is, not at its own place. + transform = VfxTransform(position = Vec3f(0f, 5f, 0f)), + emission = VfxEmission(rate = VfxValue.Const(100f), burst = 50), + spawn = VfxSpawn(lifetime = VfxValue.Const(5f), speed = VfxValue.ZERO), + subEmission = VfxSubEmission(event = event, count = VfxValue.Const(count)), + ) + ), + ) + ), + ) + + private fun VfxInstance.run(seconds: Float) { + repeat((seconds * 60f).toInt()) { update(1f / 60f) } + } + + @Test + fun `every dying particle spawns its count where it died, and nothing else is emitted`() { + val instance = VfxInstance(effect(VfxParticleEvent.DEATH, 3f), "test") + instance.update(1f / 60f) + val (rockets, sparks) = instance.emitters + val rocketRandoms = rockets.particles.random.take(rockets.particles.count).toSet() + instance.run(0.5f) + + assertEquals(0, rockets.particles.count) + assertEquals(12, sparks.particles.count) + for (slot in 0 until sparks.particles.count) { + assertEquals(2f, sparks.particles.positionX[slot], 1e-4f) + assertEquals(0f, sparks.particles.positionY[slot], 1e-4f) + } + val parents = sparks.particles.parentRandom.take(sparks.particles.count) + assertEquals(rocketRandoms, parents.toSet()) + assertTrue(parents.groupingBy { it }.eachCount().values.all { it == 3 }) + } + + @Test + fun `a living particle spawns its count per second`() { + val alive = effect(VfxParticleEvent.ALIVE, 60f).let { effect -> + val rockets = effect.nodes.single() as VfxEmitterSpec + effect.copy(nodes = listOf(rockets.copy(spawn = rockets.spawn.copy(lifetime = VfxValue.Const(1f))))) + } + val instance = VfxInstance(alive, "test") + instance.run(0.5f) + + val sparks = instance.emitters[1].particles.count + assertTrue(sparks in 116..124, "4 particles for half a second at 60 per second, got $sparks") + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGraphMaterialsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGraphMaterialsTest.kt new file mode 100644 index 000000000..109b0e25d --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/vfx/render/VfxGraphMaterialsTest.kt @@ -0,0 +1,37 @@ +package ru.hollowhorizon.hollowengine.client.vfx.render + +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraph +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderGraphProperty +import ru.hollowhorizon.hollowengine.client.shadergraph.ShaderType +import ru.hollowhorizon.hollowengine.common.vfx.VfxUniformValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VfxGraphMaterialsTest { + @Test + fun `the properties of a material are what an effect gives values to, under their own names`() { + val graph = ShaderGraph( + properties = listOf( + ShaderGraphProperty("Speed", ShaderType.FLOAT, listOf(2f)), + ShaderGraphProperty("Tint", ShaderType.VEC4, listOf(1f, 0.5f, 0f, 1f)), + ShaderGraphProperty("Noise", ShaderType.TEXTURE, texture = "hollowengine:textures/noise.png"), + ), + ) + val declaration = VfxGraphMaterials.declaration(graph) + + assertEquals(listOf("Speed", "Tint"), declaration.uniforms.map { it.name }) + assertTrue(declaration.uniform("Tint")!!.defaultValue() is VfxUniformValue.Color) + assertEquals(listOf("Noise"), declaration.samplers) + assertEquals("p_Speed", declaration.glslName("Speed")) + assertEquals("hollowengine:textures/noise.png", declaration.samplerDefaults["Noise"]) + assertTrue(declaration.drawsGlow) + } + + @Test + fun `a material is told from a core shader by its extension`() { + assertTrue(VfxGraphMaterials.isGraph("hollowengine:materials/energy_orb.material")) + assertEquals(false, VfxGraphMaterials.isGraph("hollowengine:vfx/distortion")) + assertEquals("hollowengine:materials/orb.material", VfxGraphMaterials.locationOf("assets/hollowengine/materials/orb.material")) + } +} 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 index 6a251bc39..774ec2682 100644 --- 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 @@ -35,7 +35,7 @@ class StoryScriptingAnalyzerTest { } @Test - fun `interpolation and named arguments are coloured inside a line`() { + 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}")) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/BundledStylesheetDiagnosticsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/BundledStylesheetDiagnosticsTest.kt index 180a90089..b1c1cf7b5 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/BundledStylesheetDiagnosticsTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/BundledStylesheetDiagnosticsTest.kt @@ -9,12 +9,20 @@ import kotlin.test.assertEquals */ class BundledStylesheetDiagnosticsTest { private val stylesheets = listOf( + "animator-editor.hss", "asset-manager.hss", "docking.hss", + "entity-editor.hss", + "graph.hss", "ide.hss", "image-editor.hss", + "inspector.hss", "model-editor.hss", + "notifications.hss", + "shader-graph.hss", "sounds-editor.hss", + "theme.hss", + "timeline.hss", "widgets.hss", ) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssColorHintsTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssColorHintsTest.kt index c83e55051..c11bb51f2 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssColorHintsTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/scripting/ide/ui/HssColorHintsTest.kt @@ -10,14 +10,14 @@ import kotlin.test.assertTrue class HssColorHintsTest { @Test - fun `hex and function colours are both found`() { + fun `hex and function colors are both found`() { val literals = hssColorLiterals("1px #FF8800 rgba(10, 20, 30, 0.5)") assertEquals(listOf("#FF8800", "rgba(10, 20, 30, 0.5)"), literals.map { it.text }) assertEquals(0xFFFF8800.toInt(), literals.first().argb) } @Test - fun `values that only look like colours are skipped`() { + fun `values that only look like colors are skipped`() { assertTrue(hssColorLiterals("#GGHHII").isEmpty()) assertTrue(hssColorLiterals("hollowengine:textures/gui/icons/link.svg").isEmpty()) } @@ -31,7 +31,7 @@ class HssColorHintsTest { } @Test - fun `a declaration colour gets a clickable swatch anchored on the literal`() { + fun `a declaration color gets a clickable swatch anchored on the literal`() { val source = ".panel {\n background: #123456;\n}" val hints = hssInlayHints(HssDocumentModel(source)) val swatch = hints.single { it.content.any { part -> part is InlayContent.Swatch } } diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/slots/SlotClickTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/slots/SlotClickTest.kt index cc42a126b..6b8bd3113 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/slots/SlotClickTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/common/slots/SlotClickTest.kt @@ -307,6 +307,129 @@ class SlotClickTest { assertTrue(layout.applyClick(state, SlotIntent.click(0, SlotButton.LEFT)).changed) } + @Test + fun `the same item on a slot that refuses it is taken onto the cursor, never merged in`() { + val layout = layout( + Triple("result", 1, SlotZoneRole.SOURCE), + rules = mapOf("result" to SlotRules(canInsert = SlotFilter.None)), + ) + for (button in SlotButton.entries) { + val state = layout.state { + this[0] = ItemStack(Items.BREAD, 2) + carried = ItemStack(Items.BREAD, 5) + } + + assertTrue(layout.applyClick(state, SlotIntent.click(0, button)).changed) + assertEquals(7, state.carried.count) + assertTrue(state[0].isEmpty) + } + } + + @Test + fun `taking onto a cursor without room leaves both stacks untouched`() { + val layout = layout( + Triple("result", 1, SlotZoneRole.SOURCE), + rules = mapOf("result" to SlotRules(canInsert = SlotFilter.None)), + ) + val state = layout.state { + this[0] = ItemStack(Items.BREAD, 4) + carried = ItemStack(Items.BREAD, 64) + } + + assertFalse(layout.applyClick(state, SlotIntent.click(0, SlotButton.LEFT)).changed) + assertEquals(4, state[0].count) + assertEquals(64, state.carried.count) + } + + @Test + fun `a double click gathers partial stacks first and stops at a full stack`() { + val layout = layout(Triple("a", 4, SlotZoneRole.BOTH)) + val state = layout.state { + this[0] = ItemStack(Items.APPLE, 64) + this[1] = ItemStack(Items.APPLE, 20) + this[2] = ItemStack(Items.BREAD, 5) + carried = ItemStack(Items.APPLE, 30) + } + + assertTrue(layout.applyClick(state, SlotIntent.collect(3)).changed) + + assertEquals(64, state.carried.count) + assertEquals(50, state[0].count) + assertTrue(state[1].isEmpty) + assertEquals(5, state[2].count) + } + + @Test + fun `a double click leaves copy zones and server-guarded zones alone`() { + var offset = 0 + val zones = listOf( + SlotZoneLayout("player", offset, 1).also { offset += 1 }, + SlotZoneLayout("draft", offset, 1, copyOnClick = true).also { offset += 1 }, + SlotZoneLayout("result", offset, 1, predictable = false), + ) + val layout = SlotLayout(zones, zones.map { it.name }) + val state = layout.state { + this[1] = ItemStack(Items.APPLE, 10) + this[2] = ItemStack(Items.APPLE, 10) + carried = ItemStack(Items.APPLE, 1) + } + + assertFalse(layout.applyClick(state, SlotIntent.collect(0)).changed) + assertEquals(10, state[1].count) + assertEquals(10, state[2].count) + } + + @Test + fun `a middle click clones a full stack onto an empty cursor only`() { + val layout = layout(Triple("a", 1, SlotZoneRole.BOTH)) + val state = layout.state { this[0] = ItemStack(Items.APPLE, 3) } + + assertTrue(layout.applyClick(state, SlotIntent.clone(0)).changed) + assertEquals(64, state.carried.count) + assertEquals(3, state[0].count) + + assertFalse(layout.applyClick(state, SlotIntent.clone(0)).changed) + } + + @Test + fun `a copy zone never sends or receives a shift click`() { + var offset = 0 + val zones = listOf( + SlotZoneLayout("draft", offset, 1, copyOnClick = true).also { offset += 1 }, + SlotZoneLayout("player", offset, 1), + ) + val layout = SlotLayout(zones, zones.map { it.name }) + val state = layout.state { + this[0] = ItemStack(Items.APPLE, 5) + this[1] = ItemStack(Items.BREAD, 5) + } + + assertFalse(layout.applyClick(state, SlotIntent.quickMove(0)).changed) + assertFalse(layout.applyClick(state, SlotIntent.quickMove(1)).changed) + assertEquals(5, state[0].count) + assertEquals(5, state[1].count) + } + + @Test + fun `only gestures that reach an unpredictable zone wait for the server`() { + var offset = 0 + val zones = listOf( + SlotZoneLayout("inputs", offset, 2).also { offset += 2 }, + SlotZoneLayout("result", offset, 1, SlotZoneRole.SOURCE, predictable = false).also { offset += 1 }, + SlotZoneLayout("player", offset, 2), + ) + val layout = SlotLayout(zones, zones.map { it.name }, mapOf("player" to "inputs")) + + assertTrue(layout.predicts(SlotIntent.click(0, SlotButton.LEFT))) + assertTrue(layout.predicts(SlotIntent.quickMove(3))) + assertTrue(layout.predicts(SlotIntent.distribute(listOf(0, 3), SlotDistributeMode.EVEN))) + assertFalse(layout.predicts(SlotIntent.click(2, SlotButton.LEFT))) + assertFalse(layout.predicts(SlotIntent.quickMove(2))) + assertFalse(layout.predicts(SlotIntent.distribute(listOf(0, 2), SlotDistributeMode.EVEN))) + // Out of the inputs, the ring runs through the result zone; it only sends, so it is not a target. + assertTrue(layout.predicts(SlotIntent.quickMove(0))) + } + @Test fun `an out of range slot is ignored rather than throwing`() { val layout = layout(Triple("a", 1, SlotZoneRole.BOTH)) diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt index d9fcb8901..efbb8a473 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneCurveTest.kt @@ -225,7 +225,7 @@ class CutsceneCurveTest { listOf("Test"), AnimProperty("test.value", "Value", FloatPropertyType(), 0f), ) - val curve = property.layers.first().channels.first() + val curve = property.curves.first() curve.keyframes.addAll(keys) curve.sort() return curve diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt index 7b2b9ebcc..1a20b50a1 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneMigrationTest.kt @@ -23,7 +23,7 @@ class CutsceneMigrationTest { val playback = CutscenePlaybackController().apply { setupTracks(migrated) } assertEquals(120f, migrated.origin.x, 0.001f) - assertEquals(0f, playback.translation.layers.first().channels[0].keyframes.first().value, 0.001f) + assertEquals(0f, playback.translation.curves[0].keyframes.first().value, 0.001f) playback.seek(0f) assertEquals(120f, playback.currentPose.position.x, 0.001f) @@ -37,7 +37,7 @@ class CutsceneMigrationTest { val migrated = CutsceneMigrations.convert(legacyCutscene(Vec3f(1f, 2f, 3f)), version = 1) val playback = CutscenePlaybackController().apply { setupTracks(migrated) } - val channels = playback.translation.layers.first().channels + val channels = playback.translation.curves assertEquals(3, channels.size) assertTrue(channels.all { it.keyframes.size == 1 }) } @@ -48,7 +48,7 @@ class CutsceneMigrationTest { val migrated = CutsceneMigrations.convert(legacy, version = 1) val playback = CutscenePlaybackController().apply { setupTracks(migrated) } - val x = playback.translation.layers.first().channels[0] + val x = playback.translation.curves[0] assertEquals(5f, x.valueAt(1f, 0f), 0.2f) assertTrue(x.valueAt(0.4f, 0f) < 1.5f, "an ease-in should still be crawling at 20% of the way") @@ -57,14 +57,14 @@ class CutsceneMigrationTest { @Test fun `a hidden channel is still hidden after a round trip`() { val playback = CutscenePlaybackController() - val channels = playback.translation.layers.first().channels + val channels = playback.translation.curves channels[0].keyframes += Keyframe(0f, 5f) channels[1].keyframes += Keyframe(0f, 7f) channels[1].isVisible = false val saved = playback.toData("Hidden") val reloaded = CutscenePlaybackController().apply { setupTracks(saved) } - val loaded = reloaded.translation.layers.first().channels + val loaded = reloaded.translation.curves assertTrue(loaded[0].isVisible) assertTrue(!loaded[1].isVisible, "the channel comes back hidden") diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt index 15085b571..3ab0b92d2 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/cutscene/CutsceneTest.kt @@ -3,7 +3,6 @@ package ru.hollowhorizon.hollowengine.cutscene import org.junit.jupiter.api.Test import org.lwjgl.glfw.GLFW import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.AnimProperty -import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.BlendMode import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelBounds import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.ChannelCurve import ru.hollowhorizon.hollowengine.client.ui.ide.timeline.FloatPropertyType @@ -76,9 +75,9 @@ class CutsceneTest { listOf("Camera"), AnimProperty("camera.translation", "Translation", TranslationPropertyType(), Vec3f.ZERO), ) - val layer = property.layers.first() - val x = layer.channels[0] - val y = layer.channels[1] + val layer = property + val x = layer.curves[0] + val y = layer.curves[1] x.keyframes += Keyframe(1f, 5f) y.keyframes += Keyframe(1f, 7f) @@ -90,63 +89,17 @@ class CutsceneTest { } @Test - fun `a hidden layer stops contributing to the value`() { - val controller = TimelineController() - val property = controller.addProperty( - listOf("Camera"), - AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV"), 70f), - ) - property.layers.first().channels.first().keyframes += Keyframe(0f, 90f) - val shake = property.addLayer("Shake", BlendMode.ADD) - shake.channels.first().keyframes += Keyframe(0f, 10f) - - assertEquals(100f, property.valueAt(0f), 0.001f) - - shake.isVisible = false - assertEquals(90f, property.valueAt(0f), 0.001f) - } - - @Test - fun `layer weight scales what a layer contributes`() { - val controller = TimelineController() - val property = controller.addProperty( - listOf("Camera"), - AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV"), 70f), - ) - property.layers.first().channels.first().keyframes += Keyframe(0f, 90f) - val shake = property.addLayer("Shake", BlendMode.ADD) - shake.channels.first().keyframes += Keyframe(0f, 10f) - shake.weight = 0.5f - - assertEquals(95f, property.valueAt(0f), 0.001f) - } - - @Test - fun `what layers add up to is still held to the bounds`() { - val controller = TimelineController() - val property = controller.addProperty( - listOf("Camera"), - AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV", ChannelBounds(maximum = 110f)), 70f), - ) - controller.setKey(property.layers.first().channels.first(), 0f, 100f) - val shake = property.addLayer("Shake", BlendMode.ADD) - controller.setKey(shake.channels.first(), 0f, 40f) - - assertEquals(110f, property.valueAt(0f), 0.001f) - } - - @Test - fun `a locked layer refuses new keys`() { + fun `a locked property refuses new keys`() { val controller = TimelineController().apply { workAreaEnd = 10f } val property = controller.addProperty( listOf("Camera"), AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV"), 70f), ) - val layer = property.layers.first() + val layer = property layer.isLocked = true assertTrue(controller.addKeyframes(layer, 1f).isEmpty()) - assertTrue(layer.channels.first().keyframes.isEmpty()) + assertTrue(layer.curves.first().keyframes.isEmpty()) } @Test @@ -167,14 +120,14 @@ class CutsceneTest { listOf("Camera"), AnimProperty("camera.translation", "Translation", TranslationPropertyType(), Vec3f.ZERO), ) - val layer = property.layers.first() - layer.channels.forEach { it.keyframes += Keyframe(1f, 0f) } - controller.select(listOf(layer.channels[0].keyframes.first()), additive = false) + val layer = property + layer.curves.forEach { it.keyframes += Keyframe(1f, 0f) } + controller.select(listOf(layer.curves[0].keyframes.first()), additive = false) controller.deleteSelectedKeyframes() - assertTrue(layer.channels[0].keyframes.isEmpty()) - assertFalse(layer.channels[1].keyframes.isEmpty()) + assertTrue(layer.curves[0].keyframes.isEmpty()) + assertFalse(layer.curves[1].keyframes.isEmpty()) } @Test @@ -189,7 +142,7 @@ class CutsceneTest { CameraRig.DEFAULT_FOV, ), ) - val key = controller.setKey(property.layers.first().channels.first(), 0f, -40f) + val key = controller.setKey(property.curves.first(), 0f, -40f) assertEquals(1f, key.value, 0.001f, "an FOV below one is not a shot") assertEquals(1f, property.valueAt(0f), 0.001f) @@ -203,17 +156,17 @@ class CutsceneTest { listOf("Camera"), AnimProperty("camera.translation", "Translation", TranslationPropertyType(), Vec3f.ZERO), ) - val layer = property.layers.first() - layer.channels[0].keyframes += Keyframe(1f, 3f) - layer.channels[1].keyframes += Keyframe(2f, 7f) - controller.select(layer.channels.flatMap { it.keyframes }, additive = false) + val layer = property + layer.curves[0].keyframes += Keyframe(1f, 3f) + layer.curves[1].keyframes += Keyframe(2f, 7f) + controller.select(layer.curves.flatMap { it.keyframes }, additive = false) controller.copySelectedKeyframes() controller.pasteKeyframes(5f) - assertEquals(listOf(1f, 5f), layer.channels[0].keyframes.map { it.time }) - assertEquals(listOf(2f, 6f), layer.channels[1].keyframes.map { it.time }, "spacing survives the paste") - assertEquals(7f, layer.channels[1].keyframes.last().value, 0.001f) + assertEquals(listOf(1f, 5f), layer.curves[0].keyframes.map { it.time }) + assertEquals(listOf(2f, 6f), layer.curves[1].keyframes.map { it.time }, "spacing survives the paste") + assertEquals(7f, layer.curves[1].keyframes.last().value, 0.001f) assertEquals(2, controller.selectedKeyframes.size, "the copies are what stays selected") } @@ -261,13 +214,13 @@ class CutsceneTest { listOf("Camera"), AnimProperty("camera.translation", "Translation", TranslationPropertyType(), Vec3f.ZERO), ) - val layer = property.layers.first() - val x = layer.channels[0] + val layer = property + val x = layer.curves[0] x.keyframes += Keyframe(0f, 5f) controller.focusCurves(listOf(x), additive = false) assertTrue(controller.isFocused(x)) - assertFalse(controller.isFocused(layer.channels[1])) + assertFalse(controller.isFocused(layer.curves[1])) assertEquals(5f, property.valueAt(0f).x, 0.001f, "focus is about the graph, not the result") controller.focusCurves(listOf(x), additive = false) @@ -281,7 +234,7 @@ class CutsceneTest { listOf("Camera"), AnimProperty("camera.translation", "Translation", TranslationPropertyType(), Vec3f.ZERO), ) - val stack = property.layers.first().channels.map { curve -> + val stack = property.curves.map { curve -> Keyframe(1f, 0f).also { curve.keyframes += it } } val pressed = stack.first() @@ -306,7 +259,7 @@ class CutsceneTest { listOf("Camera"), AnimProperty("camera.translation", "Translation", TranslationPropertyType(), Vec3f.ZERO), ) - val stack = property.layers.first().channels.map { curve -> + val stack = property.curves.map { curve -> Keyframe(1f, 0f).also { curve.keyframes += it } } controller.select(stack, additive = false) @@ -316,53 +269,10 @@ class CutsceneTest { assertEquals(3, controller.selectedKeyframes.size) } - @Test - fun `undo brings a deleted layer back with its keys`() { - val controller = TimelineController().apply { workAreaEnd = 10f } - val property = controller.addProperty( - listOf("Camera"), - AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV"), 70f), - ) - val base = property.layers.first() - controller.setKey(base.channels.first(), 0f, 90f) - val shake = property.addLayer("Shake", BlendMode.ADD) - controller.setKey(shake.channels.first(), 0f, 10f) - - controller.edit("Delete layer") { property.layers.remove(shake) } - assertEquals(1, property.layers.size) - - controller.undo() - - assertEquals(2, property.layers.size) - assertTrue(property.layers[1] === shake, "the layer comes back as itself, not as a copy") - assertEquals(10f, shake.channels.first().keyframes.first().value, 0.001f) - assertEquals(100f, property.valueAt(0f), 0.001f) - } - - @Test - fun `deleting a middle layer leaves the others with their own keys`() { - val controller = TimelineController().apply { workAreaEnd = 10f } - val property = controller.addProperty( - listOf("Camera"), - AnimProperty("camera.fov", "FOV", FloatPropertyType("FOV"), 0f), - ) - val first = property.layers.first() - controller.setKey(first.channels.first(), 0f, 1f) - val middle = property.addLayer("Middle", BlendMode.ADD) - controller.setKey(middle.channels.first(), 0f, 2f) - val last = property.addLayer("Last", BlendMode.ADD) - controller.setKey(last.channels.first(), 0f, 4f) - - controller.edit("Delete layer") { property.layers.remove(middle) } - - assertEquals(listOf(1f, 4f), property.layers.map { it.channels.first().keyframes.first().value }) - assertEquals(5f, property.valueAt(0f), 0.001f, "the survivors keep their own keys") - } - @Test fun `weather keys are discrete even if serialized interpolation says bezier`() { val playback = CutscenePlaybackController() - val curve = playback.weather.layers.first().channels.first() + val curve = playback.weather.curves.first() val clear = playback.timeline.setKey(curve, 0f, CutsceneWeather.CLEAR.value) playback.timeline.setKey(curve, 10f, CutsceneWeather.THUNDER.value) clear.interpolation = KeyInterpolation.BEZIER @@ -374,8 +284,8 @@ class CutsceneTest { @Test fun `environment tracks survive a cutscene round trip`() { val playback = CutscenePlaybackController() - val timeCurve = playback.timeOfDay.layers.first().channels.first() - val weatherCurve = playback.weather.layers.first().channels.first() + val timeCurve = playback.timeOfDay.curves.first() + val weatherCurve = playback.weather.curves.first() playback.timeline.setKey(timeCurve, 0f, 6_000f).interpolation = KeyInterpolation.LINEAR playback.timeline.setKey(timeCurve, 10f, 18_000f) playback.timeline.setKey(weatherCurve, 0f, CutsceneWeather.CLEAR.value) @@ -421,6 +331,6 @@ class CutsceneTest { listOf("Test"), AnimProperty("test.value", "Value", FloatPropertyType(), 0f), ) - return property.layers.first().channels.first() + return property.curves.first() } }